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
**/__pycache__/
**/*.pyc
**/*.egg-info/
backend/.venv/
backend/.pytest_cache/
backend/*.db
backend/*.db-wal
backend/*.db-shm
backend/openapi.json
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
# локальные копии бэкапов — ни в образ, ни в контекст сборки
backend/data/
data/
backups/
**/*.db
**/*.db-wal
**/*.db-shm
# Node / сборка фронта
frontend/node_modules/
frontend/dist/
@@ -23,8 +33,7 @@ backend/tests/
backend/pyproject.toml
# Прочее
.env
data/
**/.env
**/.DS_Store
*.md
.claude/
+30 -19
View File
@@ -1,13 +1,12 @@
# ═══════════════════════════════════════════════════════════════════════════
# Forbidden Stars — единый .env (dev / test / prod)
# Forbidden Stars — единый .env (dev / prod)
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
# ═══════════════════════════════════════════════════════════════════════════
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
# test — прод-клон в Docker локально (порт 8080), вход только TG
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
APP_ENV=development
@@ -15,21 +14,24 @@ APP_ENV=development
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают.
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
LOCAL_PUBLIC=local
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod.
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода.
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
VPS_TUNNEL_HOST=186.246.51.17
VPS_TUNNEL_USER=tunnel
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001.
# dev (run.ps1) → 9001, прод-контейнер → 9000.
# Раскомментируй и переопредели, только если нужен нестандартный слот.
#VPS_TUNNEL_PORT=9001
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test
# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод
# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel")))
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
@@ -42,30 +44,31 @@ ADMIN_NICKNAME=Администратор
ADMIN_BOOTSTRAP_ENABLED=true
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
# и forbidden-stars.ru (dev/test).
# и forbidden-stars.ru (dev).
TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru.
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru.
PUBLIC_BASE_URL=
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
SECRET_KEY=change-me-dev-secret-not-for-production
JWT_ALGORITHM=HS256
JWT_USER_TTL_MINUTES=10080
JWT_ADMIN_TTL_MINUTES=480
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно.
# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно.
COOKIE_DOMAIN=
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL
# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml).
# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL
# (том /data контейнера, см. docker-compose*.yml).
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
@@ -78,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
PROD_ACHIEVEMENTS_DIR=/data/achievements
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр,
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
# Перед пушем/пуллом: docker login gitea.arseniev.info
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
@@ -86,7 +89,8 @@ IMAGE_TAG=latest
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
# Лимиты под 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_CPUS=1.5
@@ -120,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
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 и где там лежит
# docker-compose.yml прода.
BACKUP_PI_SSH=pi@192.168.1.10
BACKUP_PI_DIR=~/forbidden-stars
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
# пояс устройства игрока не учитывается.
APP_TZ_OFFSET_HOURS=3
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
-13
View File
@@ -3,16 +3,3 @@
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
*.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/
backend/dev.db*
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod)
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod)
.env
!.env.example
+118 -84
View File
@@ -1,29 +1,37 @@
# Forbidden Stars — учёт партий
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
профили игроков (вход по логину и паролю или через Telegram), группы, создание партий
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями,
создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий,
уведомления, статистика и общий топ, админ-панель.
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
- **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API)
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker
- **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые
обновления приходят SSE-потоком `/api/events`)
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация»)
## Структура
```
backend/ FastAPI: ядро, REST API, БД, миграции, seed
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
frontend/ React + Vite SPA
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
docker-compose.yml
.env.example
docker-compose.yml прод на Pi: app + tunnel + backup
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
run.ps1 / run.sh единый лаунчер dev
.env.example шаблон единого .env
```
## Локальная разработка
Всё проверяется в `development` — отдельного тестового контейнера нет.
### Единый лаунчер (`run.ps1` / `run.sh`)
После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно,
решает `APP_ENV` в корневом `.env`:
После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает
`APP_ENV` в корневом `.env`):
```powershell
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
@@ -31,9 +39,9 @@ docker-compose.yml
| `APP_ENV` в `.env` | что делает лаунчер |
|---|---|
| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах |
| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) |
| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») |
| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` |
| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») |
| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) |
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
@@ -47,8 +55,8 @@ python -m venv .venv
.\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости)
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
alembic upgrade head # применит миграции и сидинг
python -m app.bootstrap # создаст/синхронизирует администратора из .env
alembic upgrade head # применит миграции и сидинг справочников
python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально)
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
```
@@ -75,6 +83,10 @@ python -m app.bootstrap
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
```
> В dev тот же bootstrap (справочники + админ из `.env`) выполняется сам при старте uvicorn,
> поэтому `python -m app.bootstrap` вручную обычно не нужен. Dev-БД, загрузки и ачивки
> лежат в `backend/data/dev/` (пути в `.env` относительные — запускайте из `backend/`).
> **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
> а в логе только `Reloading...`.
@@ -96,45 +108,42 @@ npm run dev # http://127.0.0.1:5173 или http://localhost:5
## Production (Docker на Pi)
На Pi нужны только **два файла** — `docker-compose.yml` и `.env`: образы `app`, `tunnel` и
`backup` собираются на ПК под arm64 и пушатся в Gitea-реестр, Pi тянет их сам.
```powershell
# ПК (обычно с ветки main): собрать и опубликовать образы
docker login gitea.arseniev.info
.\scripts\build-push.ps1
```
```bash
cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр.
docker compose build # на ARM64 собирается нативно
docker compose up -d # приложение на :8000, БД на томе
# Pi, папка с docker-compose.yml и .env
docker compose up -d # pull_policy: always — тянет свежие образы, без сборки
```
FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа
выполняются автоматически при старте (`entrypoint.sh`).
Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
при первом создании админа; сменить его потом — правкой `.env`, `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 — `$` дословно),
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` —
поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`,
тот же слот VPS 9000, что у Pi (одновременно не запускать).
## Аутентификация
Методы входа зависят от окружения (`APP_ENV`):
| | dev | test / prod |
| | dev | prod |
|---|---|---|
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
| Telegram Login Widget | ✓ | ✓ |
@@ -142,57 +151,69 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP,
дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей)
после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен
текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ
на вкладке аккаунтов — почту приложение не хранит.
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
(`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`).
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`).
`GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета.
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и
50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше
10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа
видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или
выйти; в dev-сборке есть кнопка «Позже (dev)». В профиле пароль меняется (нужен текущий) и
привязывается Telegram (ник не меняется). Забытый пароль задаёт админ на вкладке
аккаунтов — почту приложение не хранит. Смена или сброс пароля завершает все прежние
сессии игрока (при смене в профиле текущее устройство остаётся в системе); «Выйти»
отзывает токен этого устройства.
- **Stub-вход (по нику)** и **жёсткое удаление аккаунтов** в админке — только для разработки.
Их код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py`,
`backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из
Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development`
(`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`).
В проде аккаунт можно только отключить.
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть
данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник
занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные
методы и `telegram_bot_username` для виджета.
**Настройка Telegram (когда будете подключать реальный вход):**
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test).
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev).
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях.
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
## Окружения (dev / test / prod)
## Окружения (dev / prod)
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер):
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только
`development` и `production` — с любым другим значением бэкенд не стартует:
| | dev | test (прод-клон локально) | prod (Pi) |
|---|---|---|---|
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` |
| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) |
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram |
| | dev | prod (Pi) |
|---|---|---|
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` |
| `APP_ENV` | `development` | `production` (форсится в compose) |
| Env-файл | единый `.env` | единый `.env` (на Pi) |
| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` |
| База данных | `backend/data/dev/…` | том `db-data` (`/data`) |
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram |
| Swagger (`/api/docs`) | ✓ | ✗ |
| Fail-fast по дефолтным секретам | ✗ | ✓ |
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
`production`. Отдельного `.env.test` больше нет.
- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение
`APP_ENV` из него **игнорирует** и всегда `production`.
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
(`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это
РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`).
- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа
(`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`.
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
и локальные бэкапы (`backups/`) в образ тоже не попадают.
## Git и деплой
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь.
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь.
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
- **Деплой на Pi:** `git pull` ветки `main` → `scripts/build-push.ps1`.
- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh <dir> main` (через
`git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа).
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
@@ -200,21 +221,32 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост
они не публикуют (доступны только через домен):
У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он
не публикует (доступен только через домен):
| | домен | как выставляется | слот VPS |
|---|---|---|---|
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 |
| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают
одновременно. Dev и test делят слот 9001 → по очереди.
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен.
- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный
прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
время проверки (задача #69).
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi —
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
`pi/` (app + туннель в Docker), образ туннеля — `deploy/tunnel/`. Ключ — `deploy/tunnel/id_tunnel`.
`pi/` (app + туннель + бэкапы в Docker), образ туннеля — `deploy/tunnel/`.
## Бэкапы
Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок
БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки
скачиваются со сверкой 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 | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
этих дополнений (база — всегда).
этих дополнений (база — всегда). Справочник сидится из `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 + логин»
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
включая IP.
"""
from __future__ import annotations
+1 -1
View File
@@ -1,4 +1,4 @@
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
from __future__ import annotations
from typing import Any
+2 -2
View File
@@ -1,7 +1,7 @@
"""Общий вход: внешняя личность → пользователь → сессия.
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
и dev-входом.
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
Telegram, /auth/login, /auth/register и dev-вход.
"""
from __future__ import annotations
+1 -1
View File
@@ -2,7 +2,7 @@
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
Доступен и в dev, и в prod (в prod — единственный метод входа).
Доступен во всех окружениях — наряду со входом по логину и паролю.
"""
from __future__ import annotations
+45 -2
View File
@@ -1,9 +1,12 @@
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
"""
from __future__ import annotations
import argparse
from sqlmodel import Session, select
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.models import User
from app.seed.reference_data import seed_reference_data
from app.services import user_service
def _ensure_admin(session: Session) -> None:
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
# Администратор уже существует.
if not settings.is_development:
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
# по себе ничего не делает — применить её можно только осознанно, командой
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
return
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
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:
with Session(engine) as session:
seed_reference_data(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()
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 pathlib import Path
from pydantic import model_validator
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
@@ -12,12 +12,15 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# (исключён из образа) — там настройки приходят переменными от docker compose.
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32
# Допустимые окружения. Отдельного test-контура нет: всё проверяется в development.
_APP_ENVS = ("development", "production")
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -27,20 +30,21 @@ class Settings(BaseSettings):
case_sensitive=False,
)
# ── Главный переключатель окружения: development | test | production ───────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
# ── Главный переключатель окружения: development | production ─────────────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
app_env: str = "development"
log_level: str = "INFO"
# Публикация локального окружения (dev/test) наружу через VPS-туннель.
# Публикация локального dev-окружения наружу через VPS-туннель.
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
# от этого зависят Secure-cookie и проверка секретов при старте.
local_public: str = "local"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
# на фронте — оно приходит туда в GET /api/auth/config (#68).
app_tz_offset_hours: int = 3
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
@@ -64,8 +68,8 @@ class Settings(BaseSettings):
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
cookie_domain: str | None = None
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
# prod: только telegram) — отдельного переключателя провайдера нет.
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
telegram_bot_token: str | None = None
telegram_bot_username: str | None = None
public_base_url: str | None = None
@@ -91,17 +95,13 @@ class Settings(BaseSettings):
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
return self.app_env.lower() == "development"
@property
def is_test(self) -> bool:
return self.app_env.lower() == "test"
@property
def is_production(self) -> bool:
return self.app_env.lower() == "production"
@property
def database_url(self) -> str:
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
"""БД: dev — отдельный файл дева; prod — том контейнера (/data)."""
return self.dev_database_url if self.is_development else self.prod_database_url
@property
@@ -113,23 +113,50 @@ class Settings(BaseSettings):
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
@property
def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
нативный dev на localhost по HTTP (development + local_public=local)."""
def is_published(self) -> bool:
"""Приложение доступно снаружи по домену: production или dev, выставленный через
VPS-туннель. Не опубликован только нативный dev на localhost
(development + local_public=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
def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None
@model_validator(mode="after")
def _forbid_default_secrets_in_prod(self) -> "Settings":
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
@field_validator("app_env")
@classmethod
def _known_app_env(cls, value: str) -> str:
"""Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое
значение, кроме development, выбирает прод-пути к данным и выключает dev-вход."""
if value.lower() not in _APP_ENVS:
raise ValueError(
f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}"
)
return value
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
if self.app_env.lower() != "production":
@field_validator("app_tz_offset_hours")
@classmethod
def _known_tz_offset(cls, value: int) -> int:
"""Реальные пояса — от −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
problems: list[str] = []
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:
problems.append("ADMIN_PASSWORD не задан или дефолтный")
if problems:
where = (
"production"
if self.is_production
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
)
raise ValueError(
"Небезопасная конфигурация production — задайте секреты в .env: "
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
+ "; ".join(problems)
)
return self
+1 -1
View File
@@ -6,7 +6,7 @@
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
перезапуска, но окно короткое, а рестарты редки;
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
про SSE-шину), общий для всех воркеров.
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
(`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:
"""IP клиента для журнала аудита.
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
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())
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 при старте
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
try:
from app.bootstrap import bootstrap
@@ -152,7 +162,7 @@ async def _lifespan(_app: FastAPI):
def create_app() -> FastAPI:
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
# незачем облегчать разведку поверхности API анонимам (#61).
docs_enabled = not settings.is_production
@@ -166,7 +176,7 @@ def create_app() -> FastAPI:
)
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем.
if settings.is_development and settings.cors_origins_list:
app.add_middleware(
CORSMiddleware,
@@ -206,7 +216,7 @@ def create_app() -> FastAPI:
app.include_router(r, prefix="/api")
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
# и только если код физически есть (в прод-образе dev_*-файлы исключены
# .dockerignore, импорт просто не выполнится).
if settings.is_development:
for mod_name in ("dev_auth", "dev_admin"):
+20 -1
View File
@@ -195,6 +195,12 @@ class Group(SQLModel, table=True):
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
class GroupMember(SQLModel, table=True):
@@ -275,7 +281,8 @@ class Match(SQLModel, table=True):
Index("ix_matches_group_played", "group_id", "played_at"),
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
CheckConstraint(
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
"win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')",
name="ck_match_win_reason",
),
)
@@ -295,6 +302,14 @@ class Match(SQLModel, table=True):
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Снимок Group.nine_rounds_rule на момент старта партии.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
player_count: int = Field(sa_column=Column(Integer, nullable=False))
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_by: int = Field(
@@ -340,6 +355,10 @@ class MatchParticipant(SQLModel, table=True):
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True))
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
# на конец партии. У выбывшего миров 0.
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False)
+8 -21
View File
@@ -13,9 +13,8 @@ from app.core.errors import InvalidCredentialsError, NotFoundError
from app.core.timeutil import iso_utc
from app.db.session import get_session
from app.models import User
from app.routers.matches import attachment_read, build_match_read
from app.routers.matches import attachment_read, build_match_read, participant_inputs
from app.schemas import api as s
from app.services.match_service import ParticipantInput
from app.services import (
achievement_service,
admin_service,
@@ -154,7 +153,7 @@ def set_user_password(
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только
# отключается (PATCH is_active), удалять нельзя.
@@ -244,19 +243,6 @@ def update_match(
admin: User = Depends(get_current_admin),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match(
session,
match,
@@ -265,7 +251,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set),
participants=participants,
end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version,
)
audit_service.record(
@@ -327,16 +315,15 @@ def delete_match(
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
group_id = match_service.get_match(session, match_id).group_id # для уведомления
# До удаления: каскад унесёт участников вместе с партией.
participant_ids = notify.match_participant_ids(session, match_id)
match = match_service.get_match(session, match_id)
group_id, finished = match.group_id, match.status == "finished" # для уведомления
admin_service.delete_match(session, match_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=client_ip(request),
)
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()
+1
View File
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
return s.AuthConfig(
methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username,
tz_offset_hours=settings.app_tz_offset_hours,
)
+2 -2
View File
@@ -1,9 +1,9 @@
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
возможность удаления не попадает в прод — там аккаунт можно только
отключить (PATCH is_active).
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
+2 -2
View File
@@ -1,8 +1,8 @@
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
входа по логину остаётся только на деве.
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
без пароля остаётся только на деве (по логину и паролю входят везде).
"""
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,
my_role=member.role,
expansion_ids=group_service.group_expansion_ids(session, group_id),
nine_rounds_rule=group.nine_rounds_rule,
)
@@ -79,15 +80,19 @@ def get_group(
@router.patch("/{group_id}", response_model=s.GroupDetail)
def rename_group(
def update_group(
group_id: int,
body: s.GroupRename,
body: s.GroupUpdate,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.GroupDetail:
"""Название и домашние правила группы; меняются только переданные поля."""
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
if body.name is not None:
group_service.rename_group(session, group, body.name)
if body.nine_rounds_rule is not None:
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
return _detail(session, group_id, user.id) # type: ignore[arg-type]
+33 -18
View File
@@ -22,10 +22,30 @@ from app.services import (
user_service,
)
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
from app.services.scoring import max_rounds
router = APIRouter(prefix="/matches", tags=["matches"])
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
"""Участники правки — общее для игроцкого и админского PATCH."""
if body.participants is None:
return None
return [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
@@ -56,6 +76,8 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
)
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
@@ -79,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type]
end_round=match.end_round,
nine_rounds_rule=match.nine_rounds_rule,
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
player_count=match.player_count,
overall_comment=match.overall_comment,
created_by=match.created_by,
@@ -157,6 +182,8 @@ def finish_match(
eliminated=p.eliminated,
comment=p.comment,
faction_id=p.faction_id,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
@@ -165,6 +192,7 @@ def finish_match(
match,
finish=finish,
win_reason=body.win_reason,
end_round=body.end_round,
overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set),
expected_version=body.expected_version,
@@ -206,19 +234,6 @@ def update_match(
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match(
session,
match,
@@ -227,7 +242,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set),
participants=participants,
end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version,
)
audit_service.record(
@@ -335,9 +352,7 @@ def delete_match(
match_service.assert_can_modify(session, match, user)
match_id_val = match.id
group_id_val = match.group_id
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
finished = match.status == "finished" # после удаления статус уже не прочитать
match_service.delete_match(session, match, expected_version=expected_version)
audit_service.record(
session,
@@ -350,5 +365,5 @@ def delete_match(
user_agent=request.headers.get("user-agent"),
)
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()
+48 -6
View File
@@ -2,11 +2,18 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
WinReason = Literal["objectives", "worlds", "plastic", "resources"]
# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
# тогда, когда невыбывший участник один (match_service._check_last_standing).
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
EndRound = Annotated[int, Field(ge=1, le=9)]
Count = Annotated[int, Field(ge=0, le=99)]
# ─── Auth ────────────────────────────────────────────────────────────────────
@@ -15,6 +22,9 @@ class AuthConfig(BaseModel):
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
methods: list[str] = []
telegram_bot_username: str | None = None
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
# показывает время всем игрокам — независимо от пояса устройства (#68).
tz_offset_hours: int
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
@@ -162,8 +172,10 @@ class GroupCreate(BaseModel):
expansion_ids: list[int] = []
class GroupRename(BaseModel):
name: str
class GroupUpdate(BaseModel):
# Частичная правка: переданные поля меняются, остальные остаются как есть.
name: str | None = None
nine_rounds_rule: bool | None = None
class GroupExpansionsUpdate(BaseModel):
@@ -176,6 +188,8 @@ class GroupDetail(BaseModel):
owner_id: int
my_role: str
expansion_ids: list[int] = []
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
nine_rounds_rule: bool = False
class MemberRead(BaseModel):
@@ -269,17 +283,20 @@ class MatchFinishParticipant(BaseModel):
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
class MatchFinish(BaseModel):
participants: list[MatchFinishParticipant]
win_reason: WinReason
end_round: EndRound | None = None # раунд, в котором партия закончилась
overall_comment: str | None = None
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
expected_version: str | None = None
# Полный участник (правка завершённой партии админом).
# Полный участник (правка результатов завершённой партии).
class ParticipantInput(BaseModel):
user_id: int
faction_id: int
@@ -287,12 +304,15 @@ class ParticipantInput(BaseModel):
eliminated: bool = False
was_random: bool = False
comment: str | None = None
objectives: Count | None = None
worlds: Count | None = None
class MatchUpdate(BaseModel):
played_at: date | None = None
overall_comment: str | None = None
win_reason: WinReason | None = None
end_round: EndRound | None = None
participants: list[ParticipantInput] | None = None
expected_version: str | None = None # оптимистичная блокировка
@@ -306,6 +326,8 @@ class MatchParticipantRead(BaseModel):
eliminated: bool = False
was_random: bool
comment: str | None = None
objectives: int | None = None
worlds: int | None = None
avatar_url: str | None = None
@@ -328,6 +350,10 @@ class MatchFinishDraftData(BaseModel):
comments: dict[str, str] = {}
win_reason: WinReason | None = None
overall_comment: str | None = None
end_round: EndRound | None = None
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
objectives: dict[str, Count] = {}
worlds: dict[str, Count] = {}
class MatchFinishDraftRead(BaseModel):
@@ -346,6 +372,11 @@ class MatchRead(BaseModel):
finished_at: str | None = None
duration_minutes: int | None = None
win_reason: WinReason | None = None
end_round: int | None = None
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
# фронт берёт лимит отсюда, а не повторяет правило у себя.
nine_rounds_rule: bool = False
max_rounds: int
player_count: int
overall_comment: str | None = None
created_by: int
@@ -364,7 +395,8 @@ class OverallStats(BaseModel):
wins: int
win_rate: float
avg_place: float | None = None
score: float | None = None
# Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
score: int | None = None
class LeaderboardEntry(OverallStats):
@@ -372,6 +404,9 @@ class LeaderboardEntry(OverallStats):
nickname: str
rank: int | None = None
avatar_url: str | None = None
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
rating_confirmed: bool = False
class MatchHistory(BaseModel):
@@ -405,6 +440,8 @@ class FactionStat(BaseModel):
wins: int
win_rate: float
avg_place: float | None = None
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
score: float | None = None
@@ -468,6 +505,8 @@ class MatchListParticipant(BaseModel):
eliminated: bool = False
was_random: bool
comment: str | None = None
objectives: int | None = None
worlds: int | None = None
class MatchListItem(BaseModel):
@@ -482,6 +521,9 @@ class MatchListItem(BaseModel):
overall_comment: str | None = None
created_by: int
participants: list[MatchListParticipant] = []
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
rating_delta: float | None = None
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:
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
и после создания записи источник правды для него — БД (#72). Имя из кода получает
только новая фракция; поправить имя существующей — админкой или миграцией.
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
code_to_expansion: dict[str, Expansion] = {}
for code, name_ru, is_base, order in EXPANSIONS:
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
)
session.add(fac)
else:
fac.name_ru = name_ru
fac.expansion_id = expansion.id # type: ignore[assignment]
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:
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
он задаётся ADMIN_PASSWORD в .env."""
"""Новый пароль игроку (восстановление забытого). Пароль админа через панель не
меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
@@ -74,7 +75,7 @@ def set_player_password(session: Session, user_id: int, new_password: str) -> Us
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
# ─── Группы ──────────────────────────────────────────────────────────────────
+10
View File
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
return group
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
group.nine_rounds_rule = enabled
session.add(group)
session.commit()
session.refresh(group)
return group
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
current = session.exec(
+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.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
from app.services import group_service
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
MAX_MATCH_PLAYERS = 6
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
LAST_STANDING = "last_standing"
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
@dataclass
@@ -42,11 +45,13 @@ class FinishInput:
eliminated: bool = False
comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении
objectives: int | None = None
worlds: int | None = None
@dataclass
class ParticipantInput:
"""Полный участник (для правки завершённой партии админом)."""
"""Полный участник (для правки результатов завершённой партии)."""
user_id: int
faction_id: int
@@ -54,6 +59,8 @@ class ParticipantInput:
eliminated: bool = False
was_random: bool = False
comment: str | None = None
objectives: int | None = None
worlds: int | None = None
def round_to_30(minutes: float) -> int:
@@ -154,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
if end_round is None:
return
rmax = max_rounds(player_count, nine_rounds_rule)
if not 1 <= end_round <= rmax:
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
if not eliminated:
return worlds
if worlds:
raise ValidationError("У выбывшего игрока не может быть миров.")
return 0
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
сервер лишь не пропускает расхождение."""
alone = sum(1 for e in eliminated if not e) == 1
if alone and win_reason != LAST_STANDING:
raise ValidationError(
"Остался один невыбывший игрок — причина победы «последний выживший»."
)
if not alone and win_reason == LAST_STANDING:
raise ValidationError(
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
)
def _group_member_ids(session: Session, group_id: int) -> set[int]:
return {
m.user_id
@@ -208,12 +252,15 @@ def create_match(
)
now = utcnow()
group = group_service.get_group(session, group_id)
match = Match(
group_id=group_id,
status="in_progress",
played_at=app_today(), # дата игры — в поясе приложения (+3)
started_at=now,
player_count=len(roster),
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
nine_rounds_rule=group.nine_rounds_rule,
created_by=creator.id, # type: ignore[arg-type]
)
session.add(match)
@@ -242,6 +289,7 @@ def finish_match(
*,
finish: list[FinishInput],
win_reason: str,
end_round: int | None = None,
overall_comment: str | None = None,
overall_comment_set: bool = False,
expected_version: str | None = None,
@@ -251,6 +299,7 @@ def finish_match(
raise ConflictError("Партия уже завершена.")
if win_reason not in WIN_REASONS:
raise ValidationError("Укажите корректную причину победы.")
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
existing = {
p.user_id: p
@@ -273,12 +322,16 @@ def finish_match(
raise FactionNotAvailableError()
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
_check_last_standing(win_reason, [f.eliminated for f in finish])
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish}
for f in finish:
p = existing[f.user_id]
p.place = places[f.user_id]
p.eliminated = f.eliminated
p.comment = f.comment or None
p.objectives = f.objectives
p.worlds = worlds[f.user_id]
if f.faction_id is not None:
p.faction_id = f.faction_id
session.add(p)
@@ -294,6 +347,7 @@ def finish_match(
match.duration_minutes = round_to_30(elapsed_min)
match.status = "finished"
match.win_reason = win_reason
match.end_round = end_round
if overall_comment_set:
match.overall_comment = overall_comment or None
@@ -312,22 +366,44 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None
return session.get(MatchFinishDraft, match_id)
def _draft_counts(value: object) -> dict[str, int]:
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
if not isinstance(value, dict):
raise ValidationError("Некорректный черновик.")
out: dict[str, int] = {}
for k, v in value.items():
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
raise ValidationError("Некорректный черновик.")
out[str(k)] = v
return out
def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
раскладывает их постепенно, и промежуточное состояние может быть любым."""
раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
причине не проверяется и правило «последнего выжившего»."""
if not isinstance(data, dict):
raise ValidationError("Некорректный черновик.")
blocks = data.get("blocks") or []
eliminated = data.get("eliminated") or []
comments = data.get("comments") or {}
win_reason = data.get("win_reason")
end_round = data.get("end_round")
if not isinstance(blocks, list) or not isinstance(eliminated, list):
raise ValidationError("Некорректный черновик.")
if not isinstance(comments, dict):
raise ValidationError("Некорректный черновик.")
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
if end_round is not None and (
isinstance(end_round, bool)
or not isinstance(end_round, int)
or not 1 <= end_round <= EXTENDED_ROUNDS
):
raise ValidationError("Некорректный черновик.")
objectives = _draft_counts(data.get("objectives") or {})
worlds = _draft_counts(data.get("worlds") or {})
participant_ids = {
p.user_id
@@ -354,6 +430,9 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"comments": {str(k): str(v) for k, v in comments.items()},
"win_reason": win_reason,
"overall_comment": overall,
"end_round": end_round,
"objectives": objectives,
"worlds": worlds,
}
@@ -409,35 +488,41 @@ def update_match(
overall_comment_set: bool = False,
win_reason: str | None = None,
win_reason_set: bool = False,
end_round: int | None = None,
end_round_set: bool = False,
participants: list[ParticipantInput] | None = None,
expected_version: str | None = None,
) -> Match:
"""Правка партии: состав с местами, дата, комментарий, причина победы.
"""Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished').
Дату и общий комментарий править можно и по ходу партии — двойственного состояния
они не создают."""
results_touched = participants is not None or win_reason_set
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
иначе они оседали бы в партии со статусом in_progress, которая остаётся
в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
только status='finished'). Дату и общий комментарий править можно и по ходу
партии — двойственного состояния они не создают."""
results_touched = participants is not None or win_reason_set or end_round_set
if results_touched and match.status != "finished":
raise ConflictError(
"Результаты незавершённой партии нельзя править — сначала завершите её."
)
assert_version(match, expected_version)
if played_at is not None:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
if win_reason is not None and win_reason not in WIN_REASONS:
if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
match.win_reason = win_reason
if participants is not None:
saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
# с тем, что уже записано.
new_reason = win_reason if win_reason_set else match.win_reason
new_end_round = end_round if end_round_set else match.end_round
new_count = len(participants) if participants is not None else match.player_count
if end_round_set or participants is not None:
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
places: dict[int, int] = {}
worlds: dict[int, int | None] = {}
if participants is not None:
# Что уже записано в партии, остаётся допустимым: состав группы и набор
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
_validate_roster_basics(
@@ -451,6 +536,25 @@ def update_match(
places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants]
)
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
if participants is not None or win_reason_set:
flags = (
[p.eliminated for p in participants]
if participants is not None
else [p.eliminated for p in saved]
)
_check_last_standing(new_reason, flags)
if played_at is not None:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
match.win_reason = win_reason
if end_round_set:
match.end_round = end_round
if participants is not None:
for old in saved:
session.delete(old)
session.flush()
@@ -464,6 +568,8 @@ def update_match(
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment or None,
objectives=p.objectives,
worlds=worlds[p.user_id],
)
)
match.player_count = len(participants)
+26 -37
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from sqlmodel import Session, select
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]:
@@ -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]:
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
(история игр, публичный профиль, личная статистика) реально протухли."""
return list(
session.exec(
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
def _ratings_changed(session: Session, notified: list[int]) -> None:
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
главную, историю и профили всех, кто играл после неё, и страницы других групп.
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
skip = set(notified)
ids = [
uid
for uid in session.exec(
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
).all()
)
if uid not in skip
]
hub.publish(ids, {"type": "ratings"})
def match_changed(session: Session, match: Match) -> None:
"""Партия изменилась — уведомить всех участников её группы.
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
история и профили протухают только у игравших, поэтому их id едут в событии."""
hub.publish(
_group_member_ids(session, match.group_id),
{
"type": "match",
"match_id": match.id,
"group_id": match.group_id,
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
},
)
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
и остальных игроков (_ratings_changed)."""
members = _group_member_ids(session, match.group_id)
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
if match.status == "finished":
_ratings_changed(session, members)
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})
def match_removed(
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
) -> None:
"""Партия удалена — уведомить участников группы (обновить списки).
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
ушли каскадом, и собрать их из базы невозможно."""
hub.publish(
_group_member_ids(session, group_id),
{
"type": "match",
"match_id": match_id,
"group_id": group_id,
"participant_ids": participant_ids or [],
},
)
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
members = _group_member_ids(session, group_id)
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
if finished:
_ratings_changed(session, members)
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 — нормированные очки за место с учётом размера стола
и ничьих (competition ranking). За партию из N игроков:
points = (N - place - (tie_size - 1)/2) / (N - 1)
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md;
эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней).
Рейтинговый счёт игрока — сглаженное среднее (байесовское, формула IMDB):
score = (PRIOR_GAMES * PRIOR_MEAN + SUM(points)) / (PRIOR_GAMES + games) * 100
К реальным партиям «дописываются» PRIOR_GAMES виртуальных со средним PRIOR_MEAN:
на малой выборке рейтинг держится около 50 и лишь с опытом сходится к чистому
среднему — короткая удачная серия новичка не обгоняет стабильного ветерана.
Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b:
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
признак партии подставляется типичным и не влияет на M.
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import date, datetime
from itertools import combinations
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
MIN_GAMES = 10
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
FACTION_MIN_GAMES = 2
# Сглаживание рейтинга: сколько «виртуальных» партий и с каким средним добавляем.
PRIOR_GAMES = 10
PRIOR_MEAN = 0.5
# ─── Правила игры ────────────────────────────────────────────────────────────
# SQL-выражение сглаженного рейтинга поверх агрегата по строкам scored (s.points).
# При 0 партий SUM = NULL → score = NULL (рейтинга без игр нет).
SMOOTHED_SCORE_SQL = (
f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100"
)
# Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5).
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
WORLDS_PER_TILE = 2.2
BASE_ROUNDS = 8
# Домашнее правило группы: при 5–6 игроках играется 9 раундов.
EXTENDED_ROUNDS = 9
EXTENDED_MIN_PLAYERS = 5
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count,
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
MATCH_POINTS_SQL = (
"CASE WHEN m.player_count > 1 "
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
"/ (m.player_count - 1) "
"ELSE 1.0 END"
)
# ─── Коэффициенты (документ, 4.10) ───────────────────────────────────────────
R0 = 1500.0 # стартовый рейтинг
D = 400.0 # масштаб: разница 400 пунктов — шансы 10:1
K_MAX = 64.0 # K новичка (0 партий)
K_MIN = 16.0 # K опытного игрока
K_GAMES = 20 # за сколько партий K линейно спускается от K_MAX к K_MIN
W_TABLE = 0.5 # вес размера стола
W_TEMPO = 1.0 # вес темпа победы
W_OBJ = 0.5 # вес отрыва по целям
W_WORLDS = 0.5 # вес отрыва по мирам
MU_OBJ = 0.5 # типичный отрыв по целям
MU_WORLDS = 0.5 # типичный отрыв по мирам
M_MIN = 0.5 # страховка: одна партия не легче половины обычной…
M_MAX = 2.0 # …и не тяжелее двух
# Близость партии по типу победы — множитель пар с победителем.
CLOSENESS = {
"objectives": 1.0,
"worlds": 0.85,
"plastic": 0.7,
"resources": 0.6,
"last_standing": 1.0,
}
def smoothed_score(points_sum: float, games: int) -> float | None:
"""Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python.
def max_rounds(player_count: int, nine_rounds_rule: bool) -> int:
"""Лимит раундов партии: 9 при хоумруле группы и 5+ игроках, иначе 8."""
if nine_rounds_rule and player_count >= EXTENDED_MIN_PLAYERS:
return EXTENDED_ROUNDS
return BASE_ROUNDS
Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока).
Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной
формулы — источник расхождений, который потом ловится только глазами."""
if games <= 0:
return None # рейтинга без игр нет — как SUM(...) = NULL в SQL
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
def fair_worlds(player_count: int) -> float:
"""«Честная доля» миров на игрока — масштаб для разницы миров."""
return BOARD_TILES[player_count] * WORLDS_PER_TILE / player_count
def mu_tempo(rmax: int) -> float:
"""Типичный темп: партия закончилась в предпоследнем раунде."""
return 1.0 / (rmax - 1)
def expected(r_a: float, r_b: float) -> float:
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / D))
def k_factor(games: int) -> float:
left = max(0.0, 1.0 - games / K_GAMES)
return K_MIN + (K_MAX - K_MIN) * left
def table_weight(player_count: int) -> float:
return 1.0 + W_TABLE * (player_count - 2) / 4.0
def _clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
# ─── Партия как вход расчёта ─────────────────────────────────────────────────
@dataclass(frozen=True)
class RatedSeat:
user_id: int
place: int
faction_id: int = 0
eliminated: bool = False
objectives: int | None = None # маркеры целей на конец партии
worlds: int | None = None # дружественные миры на конец партии
@dataclass(frozen=True)
class RatedMatch:
seats: tuple[RatedSeat, ...]
win_reason: str | None = None
end_round: int | None = None # раунд, в котором партия закончилась
nine_rounds_rule: bool = False # снимок настройки группы на момент партии
id: int = 0
group_id: int = 0
played_at: date | None = None
finished_at: datetime | None = None
def pair_multiplier(m: RatedMatch, a: RatedSeat, b: RatedSeat) -> float:
"""Множитель отрыва пары; a — выше или наравне с b."""
n = len(m.seats)
tie = a.place == b.place
winner_pair = a.place == 1
add = 1.0
def diff(x: int, y: int) -> float:
return abs(x - y) if tie else max(0, x - y)
if winner_pair:
rmax = max_rounds(n, m.nine_rounds_rule)
mu = mu_tempo(rmax)
tempo = mu if m.end_round is None else (rmax - m.end_round) / (rmax - 1)
add += W_TEMPO * (tempo - mu)
if winner_pair and m.win_reason == "last_standing":
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
elif a.objectives is not None and b.objectives is not None:
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
else:
obj = MU_OBJ
add += W_OBJ * (obj - MU_OBJ)
if a.worlds is not None and b.worlds is not None:
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
else:
wor = MU_WORLDS
add += W_WORLDS * (wor - MU_WORLDS)
close = CLOSENESS.get(m.win_reason, 1.0) if winner_pair and m.win_reason else 1.0
return _clamp(add, M_MIN, M_MAX) * close
def rate_match(
ratings: dict[int, float], games: dict[int, int], m: RatedMatch
) -> tuple[dict[int, float], dict[int, float]]:
"""Изменения рейтинга участников и их результат относительно ожидания.
Возвращает (ΔR, perf): perf_i = Σ_j (S_ij − E_ij) / (N − 1) — насколько игрок
выступил выше ожидания, без множителя отрыва и K. Входные словари не мутирует."""
n = len(m.seats)
delta = {s.user_id: 0.0 for s in m.seats}
perf = {s.user_id: 0.0 for s in m.seats}
if n < 2:
return delta, perf
g = table_weight(n)
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
for a, b in combinations(m.seats, 2):
if a.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:
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
"""Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
всё равно упорядочены по настоящему значению."""
return (
-(row["score"] or 0.0),
-(row["rating"] or 0.0),
-(row["win_rate"] or 0.0),
-(row["games"] or 0),
(row["avg_place"] or 0.0),
+277 -209
View File
@@ -1,105 +1,183 @@
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
винрейт и среднее место считает по партиям группы."""
from __future__ import annotations
from typing import Any
from collections import defaultdict
from sqlalchemy import func, text
from sqlalchemy import func
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User
from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import (
FACTION_MIN_GAMES,
MATCH_POINTS_SQL,
MIN_GAMES,
SMOOTHED_SCORE_SQL,
RatedMatch,
RatedSeat,
Replay,
leaderboard_sort_key,
smoothed_score,
replay,
)
# Базовый блок: одна строка на участие с tie-aware очками.
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
SCORED_CTE = f"""
WITH tie AS (
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
WHERE m.status = 'finished' AND mp.place IS NOT NULL
GROUP BY mp.match_id, mp.place
),
scored AS (
SELECT mp.user_id AS user_id,
mp.faction_id AS faction_id,
m.id AS match_id,
m.group_id AS group_id,
m.played_at AS played_at,
mp.place AS place,
mp.was_random AS was_random,
m.player_count AS player_count,
({MATCH_POINTS_SQL}) AS points,
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
WHERE m.status = 'finished'
)
"""
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
def _round(value: Any, ndigits: int) -> float | None:
return None if value is None else round(float(value), ndigits)
def _playable_match_ids():
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
return (
select(MatchParticipant.match_id)
.group_by(MatchParticipant.match_id)
.having(func.count() >= MIN_PARTICIPANTS)
)
def _normalize(row: dict) -> dict:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
def load_history(session: Session) -> list[RatedMatch]:
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, 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 _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]:
return [m for m in history if m.group_id == group_id]
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 {
"user_id": row["user_id"],
"nickname": row["nickname"],
"games": int(row["games"] or 0),
"wins": int(row["wins"] or 0),
"win_rate": _round(row["win_rate"] or 0.0, 4),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
"games": games,
"wins": wins,
"win_rate": round(wins / games, 4),
"avg_place": round(sum(s.place for _m, s in seats) / games, 2),
"score": None if rating is None else round(rating),
}
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
where = "WHERE s.group_id = :gid" if group_id is not None else ""
sql = f"""
{SCORED_CTE}
SELECT u.id AS user_id, u.nickname AS nickname,
u.avatar_path AS avatar_path,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
{SMOOTHED_SCORE_SQL} AS score
FROM scored s
JOIN users u ON u.id = s.user_id
{where}
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
"""
params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all()
return [_normalize(dict(r)) for r in result]
def leaderboard(
session: Session,
group_id: int | None = None,
*,
history: list[RatedMatch] | None = None,
rep: Replay | None = None,
member_ids: set[int] | None = None,
) -> dict:
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и
group_stats их переиспользуют).
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
считается по всем партиям. С 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:
rows = _leaderboard_rows(session, group_id)
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
provisional = [r for r in rows if r["games"] < MIN_GAMES]
qualified.sort(key=leaderboard_sort_key)
provisional.sort(key=leaderboard_sort_key)
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
for m in shown:
for s in m.seats:
if member_ids is None or s.user_id in member_ids:
by_user[s.user_id].append((m, s))
users = (
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
if by_user
else {}
)
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):
r["rank"] = i
for r in provisional:
r["rank"] = None
for r in rows:
del r["rating"]
return {
"entries": qualified,
"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]:
"""Строки участия игрока со всеми полями, нужными витринам профиля.
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма
последних партий считаются из одного и того же набора строк. CTE джойнит участия
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с
общим числом партий, а не с числом партий игрока."""
sql = f"""
{SCORED_CTE}
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at,
s.place AS place, s.player_count AS player_count,
s.points AS points, s.is_win AS is_win,
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
e.code AS expansion_code
FROM scored s
JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id
WHERE s.user_id = :uid
"""
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
return [dict(r) for r in rows]
def _for_group(rows: list[dict], group_id: int | None) -> list[dict]:
return rows if group_id is None else [r for r in rows if r["group_id"] == group_id]
def _overall_from_rows(rows: list[dict]) -> dict:
"""Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг."""
games = len(rows)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(int(r["is_win"]) for r in rows)
return {
"games": games,
"wins": wins,
"win_rate": _round(wins / games, 4),
"avg_place": _round(sum(r["place"] for r in rows) / games, 2),
"score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1),
def _faction_breakdown(
session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int
) -> list[dict]:
by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
for m, s in seats:
by_faction[s.faction_id].append((m, s))
if not by_faction:
return []
meta = {
f.id: (f, code)
for f, code in session.exec(
select(Faction, Expansion.code)
.join(Expansion, Expansion.id == Faction.expansion_id)
.where(Faction.id.in_(list(by_faction)))
).all()
}
def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
by_faction: dict[int, list[dict]] = {}
for r in rows:
by_faction.setdefault(r["faction_id"], []).append(r)
out = []
for fid, group in by_faction.items():
games = len(group)
wins = sum(int(r["is_win"]) for r in group)
meta = group[0]
faction, expansion_code = meta[fid]
perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
out.append(
{
"faction_id": fid,
"code": meta["code"],
"name_ru": meta["name_ru"],
"expansion_code": meta["expansion_code"],
"games": games,
"wins": wins,
"win_rate": _round(wins / games, 4),
"avg_place": _round(sum(r["place"] for r in group) / games, 2),
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
# сглаживание задавило бы её к 50).
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
"code": faction.code,
"name_ru": faction.name_ru,
"expansion_code": expansion_code,
**{k: v for k, v in _summary(group, None).items() if k != "score"},
# Средний результат относительно ожидания (S − E) × 100: насколько игрок
# на фракции выступает выше рейтинговых шансов — без привязки к рейтингу.
"score": round(perf * 100, 1),
"name_ru_prepositional": faction_service.prepositional(
meta["code"], meta["name_ru"]
faction.code, faction.name_ru
),
}
)
@@ -180,15 +224,15 @@ def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
return out
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]:
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True)
def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]:
recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True)
return [
{
"place": r["place"],
"player_count": r["player_count"],
"played_at": str(r["played_at"]),
"place": s.place,
"player_count": len(m.seats),
"played_at": str(m.played_at),
}
for r in recent[:limit]
for m, s in recent[:limit]
]
@@ -208,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(
session: Session,
user_id: int,
group_id: int | None = None,
*,
rows: list[dict] | None = None,
history: list[RatedMatch] | None = None,
rep: Replay | None = None,
) -> dict:
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует)."""
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id)
overall = _overall_from_rows(scoped)
factions = _faction_breakdown_from_rows(scoped)
"""Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
переиспользует)."""
if history is None:
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]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
best = max(qualified, key=lambda f: f["score"]) if qualified else None
worst = min(qualified, key=lambda f: f["score"]) if qualified else None
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
# рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None
return {
"user_id": user_id,
"overall": overall,
"overall": _summary(seats, rep.ratings.get(user_id)),
"factions": factions,
"best_faction": best,
"worst_faction": worst,
"favorite_faction": _favorite_faction(session, user_id),
"main_faction": main,
"recent_form": _recent_form_from_rows(scoped),
"recent_form": _recent_form(seats),
"min_games": MIN_GAMES,
}
def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id)
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
games_count, last_played = session.exec(
select(func.count(), func.max(Match.played_at)).where(
Match.group_id == group_id, Match.status == "finished"
history = load_history(session)
rep = replay(history)
group_history = _for_group(history, group_id)
members = membership_service.list_members(session, group_id)
# Список игроков группы — только её текущий состав: удалённый из группы в нём не висит.
board = leaderboard(
session,
group_id,
history=history,
rep=rep,
member_ids={u.id for _m, u in members}, # type: ignore[misc]
)
).one()
last_at = str(last_played) if last_played else None
last_played = max((m.played_at for m in group_history), default=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)
faction_meta = []
sql = f"""
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
FROM factions f
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
GROUP BY f.id, f.code, f.name_ru
ORDER BY games DESC, f.sort_order
"""
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
for r in rows:
faction_meta.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"available": r["faction_id"] in available_ids,
}
factions = sorted(
session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index]
)
faction_meta = [
{
"faction_id": f.id,
"code": f.code,
"name_ru": f.name_ru,
"games": games[f.id], # type: ignore[index]
"wins": wins[f.id], # type: ignore[index]
"available": f.id in available_ids,
}
for f in factions
]
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
played_ids = {e["user_id"] for e in board["entries"]} | {
e["user_id"] for e in board["provisional"]
}
inactive = []
for _m, u in membership_service.list_members(session, group_id):
for _m, u in members:
if u.id in played_ids:
continue
rating = rep.ratings.get(u.id) # type: ignore[arg-type]
inactive.append(
{
"user_id": u.id,
@@ -288,7 +345,8 @@ def group_stats(session: Session, group_id: int) -> dict:
"wins": 0,
"win_rate": 0.0,
"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,
"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 {
"group_id": group_id,
"total_matches": games_count,
"last_match_at": last_at,
"total_matches": len(group_history),
"last_match_at": str(last_played) if last_played else None,
"leaderboard": board["entries"],
"provisional": board["provisional"],
"inactive": inactive,
@@ -316,6 +374,8 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
"objectives": p.objectives,
"worlds": p.worlds,
}
@@ -337,8 +397,12 @@ def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int,
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])
items = []
@@ -357,18 +421,18 @@ def _match_items(session: Session, matches) -> list[dict]:
"overall_comment": m.overall_comment,
"created_by": m.created_by,
"participants": parts,
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
}
)
return items
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
total = session.exec(
select(func.count()).select_from(Match).where(Match.group_id == group_id)
).one()
where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
.where(*where)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
@@ -390,27 +454,29 @@ def user_match_list(
) -> 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:
sql = f"""
{SCORED_CTE}
SELECT s.match_id AS match_id
FROM scored s
WHERE s.user_id = :uid
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
LIMIT 1
"""
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
matches = [session.get(Match, row["match_id"])] if row else []
candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine]
matches = [session.get(Match, max(candidates)[2])] if candidates else []
return {
"items": _match_items(session, matches),
"items": _match_items(session, matches, deltas),
"total": len(matches),
"limit": 1,
"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(
select(func.count())
.select_from(Match)
@@ -426,7 +492,7 @@ def user_match_list(
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"items": _match_items(session, matches, deltas),
"total": total,
"limit": limit,
"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:
board = leaderboard(session, group_id=None)
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе.
rows = _user_scored_rows(session, user_id)
profile = profile_stats(session, user_id, group_id=None, rows=rows)
# История грузится и проигрывается один раз: из неё и топ, и профиль, и блок активной
# группы (там игры и победы по группе, рейтинг — общий).
history = load_history(session)
rep = replay(history)
board = leaderboard(session, history=history, rep=rep)
profile = profile_stats(session, user_id, history=history, rep=rep)
active_group_brief = None
if active_group_id is not None:
group = session.get(Group, active_group_id)
@@ -475,7 +543,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
active_group_brief = {
"id": group.id,
"name": group.name,
**_overall_from_rows(_for_group(rows, active_group_id)),
**_overall(_for_group(history, active_group_id), rep, user_id),
}
return {
"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,
"bio": user.bio,
"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 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
@@ -205,6 +209,37 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
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):
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 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 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")
assert set(enabled_methods()) == {"password", "telegram", "stub"}
monkeypatch.setattr(settings, "app_env", "test")
assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub
monkeypatch.setattr(settings, "app_env", "production")
assert enabled_methods() == ["password", "telegram"] # prod → без stub
def test_env_flags_and_db_path(monkeypatch):
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
"""dev → файл дева; prod → том /data."""
from app.core.config import settings
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
@@ -38,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch):
monkeypatch.setattr(settings, "app_env", "development")
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
monkeypatch.setattr(settings, "app_env", "test")
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
monkeypatch.setattr(settings, "app_env", "production")
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
+93 -1
View File
@@ -1,6 +1,9 @@
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
стартует с дефолтными секретами (#59, F4, #69)."""
from __future__ import annotations
import logging
import pytest
from pydantic import ValidationError
@@ -61,7 +64,96 @@ def test_production_skips_admin_check_when_bootstrap_disabled():
def test_development_allows_defaults():
s = config.Settings(
app_env="development",
local_public="local",
secret_key=config._DEFAULT_SECRET_KEY,
admin_password=config._DEFAULT_ADMIN_PASSWORD,
)
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 fastapi.testclient import TestClient
@@ -16,20 +17,29 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
return published
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
return [e for _ids, e in published if e.get("type") == "match"]
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
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, "Хост")
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"]
gid, other_gid = group("Группа"), group("Другая")
p2 = add_group_member(engine, gid, "Игрок2")
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
p3 = add_group_member(engine, gid, "Зритель")
outsider = add_group_member(engine, other_gid, "Чужой")
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)
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]}],
)
assert started.status_code == 200, started.text
mid = started.json()["id"]
ev = _match_events(published)[-1]
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
assert p3 not in ev["participant_ids"]
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
assert _recipients(published, "match") == {me["id"], p2}
assert _recipients(published, "ratings") == set()
published.clear()
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 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):
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
Если собирать их после удаления, список всегда окажется пустым, и клиент не
обновит историю тем, кто в этой партии играл."""
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()]
def test_deleting_finished_match_reaches_players_outside_group(
client: TestClient, engine, monkeypatch
):
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
me, gid, p2, outsider, fids = _two_groups(client, engine)
mid = start_match(
client, gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
).json()["id"]
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
published = _capture_events(monkeypatch)
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)
)
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):
"""Режим best берёт партию с максимальными League Points, а не самую свежую.
"""Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую.
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место
в дуэли — отнимает."""
me = login(client, "Лучший")
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
+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 fastapi.testclient import TestClient
from sqlalchemy import event
from app.services import stats_service
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
@@ -31,10 +31,7 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать.
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
scoring.smoothed_score); этот тест ловит их расхождение."""
"""Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
me, gid, p2 = _group_with_matches(client, engine, games=4)
profile = client.get("/api/users/me/stats").json()["overall"]
@@ -47,33 +44,25 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine):
assert profile[field] == entry[field], field
def test_home_does_not_repeat_scored_cte(client: TestClient, engine):
"""Главная делает не больше двух проходов по SCORED_CTE.
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
"""Главная грузит историю партий один раз: топ, профиль и итог активной группы
считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин."""
me, gid, p2 = _group_with_matches(client, engine, games=2)
client.put(
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
)
seen: list[str] = []
calls: list[int] = []
original = stats_service.load_history
def before_execute(conn, cursor, statement, params, context, executemany):
if "WITH tie AS" in statement:
seen.append(statement)
def spy(session):
calls.append(1)
return original(session)
event.listen(engine, "before_cursor_execute", before_execute)
try:
monkeypatch.setattr(stats_service, "load_history", spy)
r = client.get("/api/home")
assert r.status_code == 200, r.text
finally:
event.remove(engine, "before_cursor_execute", before_execute)
# Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не
# для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо
# ловить (сменился путь, переименован CTE).
assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}"
assert len(calls) == 1
# Главная всё ещё показывает и профиль, и блок активной группы.
body = r.json()
assert body["profile"]["overall"]["games"] == 2
+26 -26
View File
@@ -1,6 +1,6 @@
# Публикация: домены, VPS, туннели
Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет
Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
приложение само открывает к VPS.
@@ -11,40 +11,40 @@ HTTPS твоими сертификатами и проксирует трафи
│ ▲ туннель-КОНТЕЙНЕР │
│ └── Pi : app:8000 PROD │
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
│ ▲ контейнер (test) ИЛИ │
│ ▲ ssh с ПК (dev) │
│ ├── ПК test : app:8000 │
│ └── ПК dev : vite:5173 │
│ ▲ ssh с ПК (по требованию) │
│ └── ПК dev : vite:5173 DEV │
└───────────────────────────────────────────────────┘
```
- **PROD** — Pi. `docker compose up -d` поднимает два сервиса: `app` + `tunnel`. У `app`
**портов на хост нет** — наружу его выставляет только туннель-контейнер
(`ssh -R 9000:app:8000` к VPS). Постоянно, Docker сам переподключает. См. [`pi/`](pi/README.md).
- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` поднимает
`app` + `tunnel` (`ssh -R 9001:app:8000`). Портов на хост нет — тест виден только на
`forbidden-stars.ru`. Обычно запускается лаунчером при `APP_ENV=test`.
- **PROD** — Pi. `docker compose up -d` поднимает три сервиса: `app` + `tunnel` + `backup`
(образы из Gitea-реестра, собираются на ПК `scripts/build-push.ps1`). У `app` **портов на
хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS).
Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер
(`restart: unless-stopped`). См. [`pi/`](pi/README.md).
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1`) дополнительно поднимает SSH-туннель с ПК
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`.
- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**.
PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо.
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**).
- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev.
Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя.
Ключ туннеля — **`deploy/tunnel/id_tunnel`** (приватный, в git не идёт). Его публичную
часть добавь в `authorized_keys` пользователя `tunnel` на VPS. Один и тот же ключ годится
для контейнерного туннеля (Pi/ПК) и для dev-туннеля `run.ps1`.
Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя
`tunnel` на VPS):
- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет.
- **ПК, временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер.
- **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по
умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`).
Настройка по шагам:
1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`.
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`.
3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или
ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`.
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`.
3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод,
файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`.
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их и проверяют
восстановление на тест-клоне.
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК.
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК,
в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны.
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на
VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и
`deploy/backup/` и шаблоны.
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
> (`forbiddenstars.ru` и `forbidden-stars.ru`).
@@ -62,6 +62,6 @@ HTTPS твоими сертификатами и проксирует трафи
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
(`entrypoint.sh`, `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
(10 с в `entrypoint.sh`, 2 с в `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
по `stop_grace_period`.
+3 -2
View File
@@ -4,8 +4,9 @@
FROM restic/restic:0.19.1
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini
# tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini curl
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
+107 -42
View File
@@ -16,7 +16,7 @@
4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК
5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi
6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК
7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК
7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК
8. [Восстановление прода](#8-восстановление-прода) — Pi
9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi
10. [Повседневные действия](#10-повседневные-действия)
@@ -55,8 +55,8 @@
4. удаляет старые снимки по правилам хранения;
5. проверяет, что репозитории целы.
Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных и убеждается,
что последний снимок действительно восстанавливается.
Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных (10 %) и
убеждается, что БД из последнего снимка извлекается и проходит проверку целостности.
**Словарь**
@@ -562,43 +562,42 @@
---
## Шаг 7. Учебное восстановление на тест-клоне
## Шаг 7. Проверка скачанного архива
**Где:** ПК с Docker Desktop. **Зачем:** убедиться, что бэкап действительно
восстанавливается, **до** того как это понадобится по-настоящему. Прод не затрагивается.
**Где:** ПК. **Зачем:** убедиться, что БД в скачанном снимке целая и в ней те данные, что
ожидаются, **до** того как это понадобится по-настоящему. Прод не затрагивается.
> Данные тест-клона на ПК будут заменены данными из архива. Прежние данные тест-клона
> сохраняются в его собственный снимок `pre-restore`.
> Отдельного тестового контейнера для учебного восстановления больше нет. Сам механизм
> `restore`/`import` отрабатывает только на Pi ([раздел 8](#8-восстановление-прода)); здесь
> проверяется содержимое архива.
1. Восстановите скачанный архив в тест-клон (подставьте имя своего файла):
1. Распакуйте последний скачанный архив во временную папку:
```powershell
.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260914_0400_3f2a9c1d.tar
$f = (Get-ChildItem backups\fs_*.tar | Sort-Object LastWriteTime | Select-Object -Last 1).FullName
$d = Join-Path $env:TEMP "fs-check"; New-Item -ItemType Directory -Force $d | Out-Null
tar -xf $f -C $d
```
В первый раз Docker соберёт образы тест-клона — это несколько минут.
2. Откройте `%TEMP%\fs-check\forbidden_stars.db` в [DB Browser for SQLite](https://sqlitebrowser.org)
(вкладка «Выполнить SQL») и выполните:
2. Проверьте, что приложение тест-клона поднялось:
```sql
PRAGMA integrity_check;
SELECT (SELECT count(*) FROM users WHERE role = 'player') AS players,
(SELECT count(*) FROM matches) AS matches;
```
3. Закройте DB Browser и удалите временную папку — данные в ней не зашифрованы:
```powershell
docker compose -f docker-compose.test.yml ps
docker compose -f docker-compose.test.yml logs --tail 20 app
Remove-Item -Recurse -Force (Join-Path $env:TEMP "fs-check")
```
3. Посмотрите на сайт: в `.env` на ПК временно поставьте `APP_ENV=test` и запустите
`.\run.ps1`. Тест-клон откроется на `https://forbidden-stars.ru`: проверьте топ,
профили, историю партий. Потом верните `APP_ENV=development`.
**Что должно получиться:**
- в выводе `restore-test`:
- `Развёрнутые данные в порядке: игроков N, партий M.` — те же числа, что в `list` на Pi;
- `Данные восстановлены.`;
- `Done. The test clone now runs on the restored data.`;
- `docker compose ... ps` показывает `app` в состоянии `Up … (healthy)`;
- на сайте тест-клона — данные прода на момент снимка.
> Этим же способом можно восстановить в тест-клон старые архивы `fs_*.tar.gz` прежнего
> `scripts/backup.sh`: `.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260710_140914.tar.gz`.
- `PRAGMA integrity_check` → `ok`;
- `players` и `matches` совпадают со столбцами `Игроков` / `Партий` этого снимка в `list` на Pi;
- в папке рядом с БД есть `uploads\…` (фото партий) и, если заводились, `achievements\…`.
---
@@ -646,13 +645,14 @@
| `Игроков` / `Партий` | сколько игроков и партий было в БД в этот момент — по ним легко найти «до поломки» |
| `Размер` | полный объём данных снимка |
| `Прирост` | сколько места снимок реально добавил в репозиторий |
| `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, прочие — имя, данное вручную |
| `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, `keep` + имя — именованный снимок (`run --tag имя`) |
Если локальный репозиторий повреждён или пуст, смотрите копию на VPS:
`docker compose exec backup fs-backup list vps`.
2. **По желанию, но рекомендуется:** сначала отрепетируйте на ПК:
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)).
2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК:
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем [шаг 7](#шаг-7-проверка-скачанного-архива).
Заодно у вас останется копия этого снимка вне Pi.
3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»:
@@ -737,7 +737,24 @@
```
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 — с данными.
@@ -746,21 +763,25 @@
Это защита: пустой новый Pi не перезапишет историю на VPS.
4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий):
4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий **с данными**):
```bash
docker compose exec backup fs-backup list vps
```
5. Восстановите:
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
Снимки без данных защита больше не создаёт, но снимок с `?` мог остаться от старой версии
бэкапов — такой не выбирайте.
5. Восстановите, подставив ID из `list vps`:
```bash
docker compose stop app
docker compose exec backup fs-backup restore latest --repo vps --yes
docker compose exec backup fs-backup restore <ID> --repo vps --yes
docker compose start app
```
Вместо `latest` можно указать ID из `list vps`.
Если самый свежий снимок в `list vps` — с данными, вместо ID можно написать `latest`.
6. Проверьте сайт. Затем сделайте снимок вручную, чтобы локальная копия появилась сразу:
@@ -792,7 +813,7 @@
- **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`).
- **Раз в месяц:**
- скачать снимок на ПК (`pull`);
- раз в пару месяцев сделать учебное восстановление (`restore-test`);
- проверить скачанный архив ([шаг 7](#шаг-7-проверка-скачанного-архива));
- удалить с ПК старые архивы — они не зашифрованы.
- **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что
бэкапы перестали проходить (причину покажет `fs-backup status`).
@@ -817,6 +838,42 @@ ID одного и того же снимка в `local` и `vps` разные
**Обновить образ бэкапа** (после изменений в `deploy/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` данные
> не трогает.
@@ -838,6 +895,8 @@ docker compose logs --tail 100 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_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
| `Репозиторий 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` |
| `service "backup" is not running` | контейнер не запущен | `docker compose up -d backup` |
| `permission denied while trying to connect to the Docker daemon socket` | пользователь Pi не в группе `docker` | `sudo usermod -aG docker $USER`, перезайти по SSH |
| `docker compose ps` → `backup` `unhealthy` | последний успешный бэкап старше `BACKUP_MAX_AGE_HOURS` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице |
| `docker compose ps` → `backup` `unhealthy` | последний успешный бэкап старше `BACKUP_MAX_AGE_HOURS`, успешных бэкапов ещё не было или не задан `BACKUP_PASSWORD` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице |
**Про `recover`.** Команда сама определяет, на каком этапе оборвалось восстановление:
- если замена успела пройти во всех томах — дочищает промежуточные папки, восстановленные
@@ -885,7 +944,7 @@ docker volume rm <имя тома>
| `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` |
| `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) |
| `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `pull` |
| `restore-test`: `Import failed … The test clone data was not changed` | архив повреждён или неполный | скачать заново (`pull`); текст ошибки выше в выводе |
| шаг 7: `integrity_check` не `ok` или счётчики не совпадают с `list` | архив повреждён или скачан не тот снимок | удалить файл из `backups\` и скачать заново (`pull -Snapshot <ID>`); если повторяется — `fs-backup verify` на Pi |
---
@@ -906,9 +965,15 @@ docker volume rm <имя тома>
| `recover` | разбор прерванного восстановления |
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
| `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` | краткая справка |
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
Без `--yes` команды `restore` и `import` только показывают, что собираются сделать.
Флаг `--no-pre-restore` у `restore`/`import` пропускает страховочный снимок — используйте,
только если текущие данные точно не нужны.
### Скрипт ПК
@@ -918,11 +983,9 @@ docker volume rm <имя тома>
|---|---|
| `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК |
| `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 |
| `restore-test -File <архив>` | учебное восстановление в локальный тест-клон |
| `-Target test` | выполнить `status`/`list`/`now`/`verify`/`pull` на локальном тест-клоне |
В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`,
`pull <ID> --repo vps`, `restore-test <архив>`, `--test` первым аргументом.
`pull <ID> --repo vps`.
### Переменные `.env`
@@ -940,6 +1003,8 @@ docker volume rm <имя тома>
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
| `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_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
@@ -970,7 +1035,7 @@ docker volume rm <имя тома>
- [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5)
- [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5)
- [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6)
- [ ] Учебное восстановление на тест-клоне прошло, данные на месте (шаг 7)
- [ ] Скачанный архив проверен: БД целая, числа совпадают с `list` (шаг 7)
**Через сутки**
- [ ] В `list` появился снимок с меткой `scheduled` в 04:00
+23 -1
View File
@@ -5,6 +5,14 @@ set -eu
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-петля и спам в логах) — ждём,
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
if [ -z "${BACKUP_PASSWORD:-}" ]; then
@@ -29,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
fi
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 "Первый бэкап не удался — следующая попытка по расписанию."
fi
+243 -4
View File
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
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-потоком.
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() {
[ -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"; }
# ─── 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 не создаются).
db_ok() {
[ -s "$1" ] || return 1
@@ -178,6 +275,17 @@ forget_repo() {
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
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
for _repo in $(repos); do
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
@@ -236,11 +344,18 @@ cmd_run() {
esac
shift
done
_players="?"
_matches="?"
_ok_repos=""
_failed=""
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
trap '_rc=$?; notify_run "$_rc"' EXIT
require_password
setup_ssh
take_lock
warn_leftovers
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
log "Снимок данных: консистентная копия БД…"
stage_db
@@ -252,7 +367,6 @@ cmd_run() {
log "БД в порядке: игроков $_players, партий $_matches."
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
_failed=""
for _repo in $(repos); do
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
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
_sid="$(latest_short_id "$_repo")"
mark "run-$_repo" ok "снимок $_sid"
_ok_repos="$_ok_repos $_repo:$_sid"
log "OK: репозиторий $_repo, снимок $_sid."
else
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
# ─── verify ───────────────────────────────────────────────────────────────────
cmd_verify() {
_verified=""
_failed=""
trap '_rc=$?; notify_verify "$_rc"' EXIT
require_password
setup_ssh
take_lock
_failed=""
for _repo in $(repos); do
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
_tmp="$RUNTIME_DIR/verify.db"
@@ -378,6 +495,7 @@ cmd_verify() {
&& db_ok "$_tmp"; then
_counts="$(db_counts "$_tmp")"
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
else
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
}
# ─── 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() {
cat <<'EOF'
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
снимок в tar без сжатия (нужен exec -T)
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
telegram bot бот-слушатель команд (контейнер запускает его сам)
Прочее:
init создать репозитории (делается автоматически)
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
@@ -833,6 +1071,7 @@ case "$_cmd" in
restic) cmd_restic "$@" ;;
health) cmd_health "$@" ;;
has-snapshots) cmd_has_snapshots "$@" ;;
telegram) cmd_telegram "$@" ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac
+57 -6
View File
@@ -2,12 +2,13 @@
На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл
ключа туннеля не нужны:
- образы (`app` + `tunnel`) тянутся из Gitea-реестра (`pull_policy: always`);
- образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`);
- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64).
`docker compose up` поднимает два контейнера: `app` (FastAPI+SPA, портов на хост нет) и
`tunnel` (`ssh -R 9000:app:8000` к VPS). Публичная точка — VPS, домен `forbiddenstars.ru`
(Pi за CGNAT — туннель стучится наружу сам).
`docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет),
`tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic,
см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель
стучится наружу сам).
---
@@ -34,6 +35,32 @@ newgrp docker # применить группу
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-ключ для туннеля
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
@@ -86,6 +113,23 @@ IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по
IMAGE_TAG=latest
```
`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать.
Блок `BACKUP_*` можно оставить пустым: бэкапы включаются позже, по
[`deploy/backup/README.md`](../backup/README.md).
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
>
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
> его не сменить:
> 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. Запуск
```bash
@@ -94,7 +138,8 @@ docker compose up -d # pull_policy: always → тянет обр
docker compose ps # app healthy → поднимется tunnel
docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@...
```
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`.
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`
(если админа ещё нет).
## 5. Проверка
- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку).
@@ -103,9 +148,12 @@ docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:a
## Обновление
```bash
# Pi: docker compose exec backup fs-backup run --tag before-update # по желанию, если бэкапы включены
# ПК: .\scripts\build-push.ps1
# Pi: docker compose up -d # always-pull подтянет свежий образ
# Pi: docker compose up -d # always-pull подтянет свежие образы
```
Если в новой версии менялся `docker-compose.yml` или `.env.example`, сначала скачайте
свежий `docker-compose.yml` (шаг 3) и допишите новые переменные в `.env`.
## Автозапуск после перезагрузки
Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
@@ -132,3 +180,6 @@ docker compose exec backup fs-backup list # хронология снимк
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
сертификатом (иначе хост в `/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}"
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте).
# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте).
mkdir -p /root/.ssh
KEY=/root/.ssh/id_tunnel
if [ -n "${TUNNEL_KEY_B64:-}" ]; then
+6 -6
View File
@@ -1,8 +1,8 @@
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
#
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
#
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
@@ -38,10 +38,10 @@
-Server
}
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61).
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки: строгая политика легко
# ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с telegram.org + iframe
# oauth.telegram.org) и EventSource (/api/events). Раскомментировать после проверки,
# что вход и реал-тайм работают (#61).
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
+15 -10
View File
@@ -1,11 +1,11 @@
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test).
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev).
```
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV
```
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит
@@ -51,7 +51,9 @@ install -d -m 700 -o tunnel -g tunnel /home/tunnel/.ssh
install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys
```
Публичные ключи Pi и ПК добавишь в `/home/tunnel/.ssh/authorized_keys` на шагах настройки
Pi (`deploy/pi/README.md`) и ПК (через `ssh-copy-id`).
Pi (`deploy/pi/README.md`) и ПК (`deploy/tunnel/id_tunnel.pub` и/или ключ по умолчанию из
`~/.ssh` для dev-туннеля). Ключи **дописывай** (`>>`), а не перезаписывай файл (`>`):
иначе туннель другого хоста перестанет пускать.
## 5. Сертификаты
@@ -67,11 +69,11 @@ Caddy читает **PEM** (текст с `-----BEGIN CERTIFICATE-----`). Рас
Удобно собрать прямо на VPS — залей свои файлы и склей:
```bash
mkdir -p /etc/caddy/certs/forbidden-stars.ru /root/certs-tmp
# с локальной машины (пример для домена forbidden-stars.ru):
mkdir -p /etc/caddy/certs/forbidden-stars.ru /etc/caddy/certs/forbiddenstars.ru /root/certs-tmp
# с локальной машины (пример для домена forbidden-stars.ru; имена файлов — свои):
scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/
# на VPS — собрать fullchain (leaf + промежуточный) и положить ключ:
cat /root/certs-tmp/www_forbidden_stars_ru_2026_12_31.crt /root/certs-tmp/intermediate_pem_globalsign_ssl_dv_free_1.crt \
cat /root/certs-tmp/forbidden-stars.crt /root/certs-tmp/intermediate.crt \
> /etc/caddy/certs/forbidden-stars.ru/fullchain.pem
cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem
# то же для forbiddenstars.ru (свои crt/intermediate/key), затем права
@@ -96,13 +98,16 @@ scp deploy/vps/maintenance.html root@186.246.51.17:/etc/caddy/maintenance/mainte
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
```
> Заглушка живёт в сниппете `(offline)` Caddyfile: при ответе апстрима 502/503/504
> Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` со статусом 503 и `Cache-Control: no-store`.
> Заглушка живёт в сниппете `(edge)` Caddyfile (там же security-заголовки и `no-store` для
> HTML): при ответе апстрима 502/503/504 Caddy отдаёт `/etc/caddy/maintenance/maintenance.html`
> со статусом 503 и `Cache-Control: no-store`. Для `/api/events` (SSE) у каждого домена
> отдельный `handle` без `encode` и с `flush_interval -1`.
> Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен,
> файл читается на каждый запрос).
## 7. Проверка
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`).
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК
(лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`).
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
(HTTP 503), это ожидаемо.
+1 -1
View File
@@ -5,7 +5,7 @@
#
# Отличия от docker-compose.yml (прод на Pi):
# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится;
# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК.
# • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК.
# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck).
#
# Запуск: docker compose -f docker-compose.temp.yml up -d --build
-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:
app:
build: .
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi).
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
# .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
restart: unless-stopped
@@ -42,7 +42,9 @@ services:
timeout: 5s
retries: 3
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}
security_opt:
- no-new-privileges:true
@@ -75,8 +77,8 @@ services:
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
# восстановление — deploy/backup/README.md.
# только BACKUP_* (секреты приложения сюда не передаются; исключение — токен бота для
# отчётов в Telegram). Всё про настройку и восстановление — deploy/backup/README.md.
backup:
build: ./deploy/backup
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
@@ -84,7 +86,7 @@ services:
restart: unless-stopped
environment:
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены
BACKUP_HOSTNAME: fs-prod # имя хоста в снимках (у тест-клона — fs-test)
BACKUP_HOSTNAME: fs-prod # имя хоста в снимках
BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *}
BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
@@ -97,6 +99,10 @@ services:
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
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}
volumes:
- 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 type { ReactNode } from "react";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/router";
import { Spinner } from "./components/Spinner";
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() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<AppTimeZone>
<RouterProvider router={router} />
</AppTimeZone>
</ToastProvider>
</QueryClientProvider>
);
+19 -4
View File
@@ -1,3 +1,5 @@
import type { QueryClient } from "@tanstack/react-query";
export const qk = {
me: ["me"] as const,
adminMe: ["adminMe"] as const,
@@ -30,10 +32,11 @@ export const qk = {
};
/**
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
* в этом файле тихо разойдётся с местами, где он написан строкой.
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
* написан строкой.
*/
export const matchAffectedKeys = [
qk.home,
@@ -42,3 +45,15 @@ export const matchAffectedKeys = [
["userMatches"],
["publicProfile"],
] 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;
options?: never;
head?: never;
/** Rename Group */
patch: operations["rename_group_api_groups__group_id__patch"];
/**
* Update Group
* @description Название и домашние правила группы; меняются только переданные поля.
*/
patch: operations["update_group_api_groups__group_id__patch"];
trace?: never;
};
"/api/groups/{group_id}/expansions": {
@@ -1427,7 +1430,7 @@ export interface components {
/** Duration Minutes */
duration_minutes?: number | null;
/** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */
player_count: number;
/** Created By */
@@ -1607,6 +1610,8 @@ export interface components {
methods: string[];
/** Telegram Bot Username */
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: {
@@ -1766,6 +1771,11 @@ export interface components {
* @default []
*/
expansion_ids: number[];
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
};
/** GroupExpansionsUpdate */
GroupExpansionsUpdate: {
@@ -1775,11 +1785,6 @@ export interface components {
*/
expansion_ids: number[];
};
/** GroupRename */
GroupRename: {
/** Name */
name: string;
};
/** GroupStats */
GroupStats: {
/** Group Id */
@@ -1811,6 +1816,13 @@ export interface components {
/** Min Games */
min_games: number;
};
/** GroupUpdate */
GroupUpdate: {
/** Name */
name?: string | null;
/** Nine Rounds Rule */
nine_rounds_rule?: boolean | null;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
@@ -1904,6 +1916,11 @@ export interface components {
rank?: number | null;
/** Avatar Url */
avatar_url?: string | null;
/**
* Rating Confirmed
* @default false
*/
rating_confirmed: boolean;
};
/** MatchCreate */
MatchCreate: {
@@ -1920,7 +1937,9 @@ export interface components {
* Win Reason
* @enum {string}
*/
win_reason: "objectives" | "worlds" | "plastic" | "resources";
win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing";
/** End Round */
end_round?: number | null;
/** Overall Comment */
overall_comment?: string | null;
/** Expected Version */
@@ -1951,9 +1970,25 @@ export interface components {
[key: string]: string;
};
/** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Overall Comment */
overall_comment?: string | null;
/** End Round */
end_round?: number | null;
/**
* Objectives
* @default {}
*/
objectives: {
[key: string]: number;
};
/**
* Worlds
* @default {}
*/
worlds: {
[key: string]: number;
};
};
/** MatchFinishDraftRead */
MatchFinishDraftRead: {
@@ -1980,6 +2015,10 @@ export interface components {
comment?: string | null;
/** Faction Id */
faction_id?: number | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
};
/**
* MatchHistory
@@ -2034,7 +2073,7 @@ export interface components {
/** Duration Minutes */
duration_minutes?: number | null;
/** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */
player_count: number;
/** Overall Comment */
@@ -2046,6 +2085,8 @@ export interface components {
* @default []
*/
participants: components["schemas"]["MatchListParticipant"][];
/** Rating Delta */
rating_delta?: number | null;
};
/** MatchListParticipant */
MatchListParticipant: {
@@ -2068,6 +2109,10 @@ export interface components {
was_random: boolean;
/** Comment */
comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
};
/** MatchParticipantRead */
MatchParticipantRead: {
@@ -2090,6 +2135,10 @@ export interface components {
was_random: boolean;
/** Comment */
comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
/** Avatar Url */
avatar_url?: string | null;
};
@@ -2113,7 +2162,16 @@ export interface components {
/** Duration Minutes */
duration_minutes?: number | null;
/** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
/** Max Rounds */
max_rounds: number;
/** Player Count */
player_count: number;
/** Overall Comment */
@@ -2146,7 +2204,9 @@ export interface components {
/** Overall Comment */
overall_comment?: string | null;
/** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/** Participants */
participants?: components["schemas"]["ParticipantInput"][] | null;
/** Expected Version */
@@ -2294,6 +2354,10 @@ export interface components {
was_random: boolean;
/** Comment */
comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
};
/** PasswordChange */
PasswordChange: {
@@ -3177,7 +3241,7 @@ export interface operations {
};
};
};
rename_group_api_groups__group_id__patch: {
update_group_api_groups__group_id__patch: {
parameters: {
query?: never;
header?: never;
@@ -3188,7 +3252,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["GroupRename"];
"application/json": components["schemas"]["GroupUpdate"];
};
};
responses: {
+8 -2
View File
@@ -30,8 +30,14 @@ function Row({
</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 ?? "—"}
</div>
</>
+6 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format";
import type { MatchListItem } from "../domain/types";
import { MatchListView } from "./MatchList";
import { RatingDelta } from "./RatingDelta";
/**
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
@@ -53,10 +54,11 @@ export function MatchHistory({
return (
<button
key={m.id}
className="card row-between"
className="card"
style={{ margin: 0, width: "100%", textAlign: "left" }}
onClick={() => navigate(`/match/${m.id}`)}
>
<div className="row-between">
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<span
className={
@@ -76,6 +78,9 @@ export function MatchHistory({
<span className="muted small" style={{ flexShrink: 0 }}>
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
</span>
</div>
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
</button>
);
})}
+4 -1
View File
@@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format";
import { winReasonLabel } from "../domain/winReasons";
import type { MatchListItem } from "../domain/types";
import { RatingDelta } from "./RatingDelta";
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
* отфильтрованной по status="finished". */
* отфильтрованной по status="finished". В истории профиля у партии есть rating_delta
* владельца — она идёт последней строкой; у списка группы её нет. */
export function MatchListView({ items }: { items: MatchListItem[] }) {
const navigate = useNavigate();
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
@@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
</div>
))}
</div>
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
</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 { Avatar } from "./Avatar";
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
export interface PlacePlayer {
user_id: number;
@@ -10,6 +11,10 @@ export interface PlacePlayer {
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: "merge"; idx: number }
| { type: "insert"; idx: number }
@@ -28,7 +33,8 @@ interface DragState {
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
* выбывший. В каждом блоке — строка комментария об игроке.
* выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
*
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
@@ -41,6 +47,8 @@ export function PlaceEditor({
comments,
onChange,
onComment,
counts,
onCount,
}: {
players: PlacePlayer[];
blocks: number[][];
@@ -48,6 +56,8 @@ export function PlaceEditor({
comments: Record<number, string>;
onChange: (blocks: number[][], eliminated: number[]) => void;
onComment: (userId: number, text: string) => void;
counts?: Counts;
onCount?: (userId: number, field: CountField, value: number | null) => void;
}) {
const listRef = useRef<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
className="rank-comment"
placeholder="Комментарий об игроке"
@@ -233,6 +260,15 @@ export function PlaceEditor({
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 (
<div>
+1 -1
View File
@@ -35,7 +35,7 @@ export function ProfileStatsCard({
<b>{o.avg_place ?? "—"}</b>
</div>
<div className="row-between">
<div>Очки (рейтинг)</div>
<div>Рейтинг</div>
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
</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");
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
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, "игрок", "игрока", "игроков").
@@ -18,6 +25,14 @@ export function plural(n: number, one: string, few: string, many: string): strin
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 {
if (minutes == null) return "—";
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;
}
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
export function toAppLocalInput(iso: string): string {
const d = shifted(iso);
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 {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
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();
}
+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 const LAST_STANDING: WinReason = "last_standing";
// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший»
// сюда не входит: она ставится сама, когда невыбывший игрок остался один.
export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "objectives", label: "По целям" },
{ code: "worlds", label: "По мирам" },
@@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "resources", label: "По ресурсам" },
];
const LABELS: Record<string, string> = Object.fromEntries(
WIN_REASONS.map((w) => [w.code, w.label]),
);
const LABELS: Record<string, string> = {
...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])),
[LAST_STANDING]: "Последний выживший",
};
export function winReasonLabel(code: string | null | undefined): string {
return code ? LABELS[code] ?? code : "—";
}
/**
* Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно
* один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда
* выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать.
*/
export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null {
if (survivors === 1) return LAST_STANDING;
return current === LAST_STANDING ? null : current;
}
+6 -3
View File
@@ -75,14 +75,15 @@ export function useCreateGroup() {
});
}
export function useRenameGroup(groupId: number) {
export function useUpdateGroup(groupId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (name: string) =>
// Частичная правка: название и/или домашние правила (например, 9 раундов).
mutationFn: async (body: { name?: string; nine_rounds_rule?: boolean }) =>
unwrap(
await api.PATCH("/api/groups/{group_id}", {
params: { path: { group_id: groupId } },
body: { name },
body,
}),
),
onSuccess: () => {
@@ -137,6 +138,8 @@ export function useRemoveMember(groupId: number) {
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
// Список игроков группы показывает только текущий состав — удалённый из него уходит.
qc.invalidateQueries({ queryKey: qk.groupStats(groupId) });
qc.invalidateQueries({ queryKey: qk.me });
},
});
+4 -6
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { matchAffectedKeys, qk } from "../api/queryKeys";
import { invalidateRatingViews, qk } from "../api/queryKeys";
import type {
FactionRead,
MatchCreate,
@@ -83,8 +83,7 @@ export function useFinishMatch() {
onSuccess: (m) => {
qc.invalidateQueries({ queryKey: qk.match(m.id) });
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
invalidateRatingViews(qc);
},
});
}
@@ -103,9 +102,8 @@ export function useUpdateMatch() {
onSuccess: (m) => {
qc.setQueryData(qk.match(m.id), m);
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
// Места изменились — значит изменились лидерборд, история и профили.
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
// Места изменились — значит изменились рейтинги, топ, истории и профили.
invalidateRatingViews(qc);
},
});
}
+16 -30
View File
@@ -1,15 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { matchAffectedKeys, qk } from "../api/queryKeys";
import { useMe } from "./auth";
import { invalidateRatingViews, qk } from "../api/queryKeys";
interface ServerEvent {
type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements";
/** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
type:
| "match"
| "match_draft"
| "ratings"
| "group"
| "invitations"
| "notifications"
| "announcements";
match_id?: number;
group_id?: number;
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
participant_ids?: number[];
}
/**
@@ -19,11 +24,6 @@ interface ServerEvent {
*/
export function useServerEvents(enabled: boolean) {
const qc = useQueryClient();
const { data: me } = useMe();
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
// SSE-соединение каждый раз, когда профиль перезапрашивается.
const myId = useRef<number | null>(null);
myId.current = me?.id ?? null;
useEffect(() => {
if (!enabled) return;
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.group_id != null) {
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") {
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
+29 -4
View File
@@ -13,8 +13,8 @@ import {
useGroup,
useGroupMembers,
useRemoveMember,
useRenameGroup,
useSetGroupExpansions,
useUpdateGroup,
} from "../hooks/groups";
import { useExpansions } from "../hooks/reference";
@@ -25,7 +25,7 @@ export function GroupSettingsPage() {
const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0);
const renameGroup = useRenameGroup(groupId ?? 0);
const updateGroup = useUpdateGroup(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast();
@@ -55,7 +55,7 @@ export function GroupSettingsPage() {
const trimmed = nameValue.trim();
if (!trimmed || trimmed === group.name) return;
try {
await renameGroup.mutateAsync(trimmed);
await updateGroup.mutateAsync({ name: trimmed });
setName(null);
toast.show("Название сохранено");
} catch (e) {
@@ -63,6 +63,15 @@ export function GroupSettingsPage() {
}
};
const saveNineRounds = async (enabled: boolean) => {
try {
await updateGroup.mutateAsync({ nine_rounds_rule: enabled });
toast.show(enabled ? "9 раундов при 5–6 игроках включено" : "Правило 9 раундов выключено");
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveExpansions = async () => {
try {
await setExpansions.mutateAsync([...selected]);
@@ -95,7 +104,7 @@ export function GroupSettingsPage() {
<button
className="btn btn-primary"
onClick={saveName}
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
disabled={updateGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
>
Сохранить
</button>
@@ -127,6 +136,22 @@ export function GroupSettingsPage() {
)}
</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">
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}
<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";
/**
* Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг»
* (как считаются очки игроков). Текст соответствует backend/app/services/scoring.py.
* Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг».
* Текст соответствует backend/app/services/scoring.py и docs/rating/rating-system.md:
* коэффициенты, пороги и примеры менять вместе с ними.
*/
export function HelpPage() {
const [showRating, setShowRating] = useState(false);
@@ -32,50 +33,118 @@ export function HelpPage() {
{showRating && (
<>
<div className="card">
<h3>Очки игроков (рейтинг)</h3>
<h3>Рейтинг игроков</h3>
<p className="small">
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые
в зачёт не идут.
Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает
с <b>1500</b>. Разница в <b>400</b> пунктов означает шансы 10 к 1 в пользу более
сильного.
</p>
<p className="small">
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков
за столом, поэтому победа за большим столом ценится выше, чем за маленьким:
Считаются только <b>завершённые</b> партии, по порядку их игры. Правка или удаление
старой партии пересчитывает и все последующие.
</p>
<div className="formula">
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1)
<p className="small">
Партия раскладывается на пары игроков. В каждой паре фактический результат (выше —
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>
<p className="small" style={{ marginTop: 10 }}>
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</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 и с опытом сходится к реальному
среднему — стабильные результаты на длинной дистанции ценятся выше короткой
удачной серии.
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
делят последнее место, но между собой не сравниваются: в этой партии все они
проиграли, так что пара двух выбывших рейтинг не двигает.
</p>
</div>
<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="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">
<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>
<p className="muted small" style={{ marginBottom: 0 }}>
Рейтинг показывается целым числом, но считается без округления.
</p>
</div>
<div className="card">
<h3>Один рейтинг на всё приложение</h3>
<p className="small" style={{ margin: 0 }}>
Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый
в общем топе, в профиле и на странице группы. На странице группы по партиям этой
группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус
«Новичок» там общие.
</p>
</div>
<div className="card">
@@ -91,6 +160,7 @@ export function HelpPage() {
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
<li><b>Лучшая партия</b> в профиле — та, что принесла больше всего рейтинга.</li>
<li>
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
и затем по нику.
+192 -86
View File
@@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { MatchMedia } from "../components/MatchMedia";
import { MatchOutcomeFields } from "../components/MatchOutcomeFields";
import { PickerSelect } from "../components/PickerSelect";
import { PlaceEditor } from "../components/PlaceEditor";
import { type CountField, type Counts, PlaceEditor } from "../components/PlaceEditor";
import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner";
import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings";
import { formatDate, formatDuration, formatTime } from "../domain/format";
import type { MatchFinishDraftData } from "../domain/types";
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
import type { MatchFinishDraftData, MatchRead } from "../domain/types";
import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
import {
@@ -24,14 +26,50 @@ import {
} from "../hooks/matches";
import { useGroupFactions } from "../hooks/reference";
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
type Participant = MatchRead["participants"][number];
const countsOf = (participants: Participant[]): Counts =>
Object.fromEntries(
participants.map((p) => [p.user_id, { objectives: p.objectives ?? null, worlds: p.worlds ?? null }]),
);
const countsFromDraft = (participants: Participant[], data: MatchFinishDraftData): Counts =>
Object.fromEntries(
participants.map((p) => [
p.user_id,
{
objectives: data.objectives?.[String(p.user_id)] ?? null,
worlds: data.worlds?.[String(p.user_id)] ?? null,
},
]),
);
// Черновик хранит только заполненные поля: {user_id строкой: число}.
const countDict = (counts: Counts, field: CountField): Record<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() {
const { matchId } = useParams();
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
const parsed = matchId ? Number(matchId) : NaN;
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: groupFactions } = useGroupFactions(match?.group_id ?? null);
const finish = useFinishMatch();
@@ -47,7 +85,10 @@ export function MatchDetailPage() {
const [blocks, setBlocks] = useState<number[][] | null>(null);
const [elim, setElim] = useState<number[]>([]);
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 [error, setError] = useState<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false);
@@ -86,23 +127,38 @@ export function MatchDetailPage() {
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
// иначе правка соседа перетёрла бы тайл прямо под рукой.
const incoming = match?.finish_draft;
const participants = match?.participants;
useEffect(() => {
if (!incoming || match?.status !== "in_progress") return;
if (!incoming || !participants || match?.status !== "in_progress") return;
if (incoming.updated_by === me?.id) return;
if (incoming.updated_at === appliedDraftAt.current) return;
if (Date.now() - lastLocalEdit.current < 1500) return;
setBlocks(incoming.data.blocks);
setElim(incoming.data.eliminated);
setComments(incoming.data.comments);
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason);
setCounts(countsFromDraft(participants, incoming.data));
setWinReason(incoming.data.win_reason ?? null);
setEndRound(incoming.data.end_round ?? null);
setOverall(incoming.data.overall_comment ?? "");
appliedDraftAt.current = incoming.updated_at;
}, [incoming, match?.status, me?.id]);
}, [incoming, participants, match?.status, me?.id]);
if (isLoading) return <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";
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
@@ -110,6 +166,7 @@ export function MatchDetailPage() {
const finishComments: Record<number, string> =
comments ??
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""]));
const finishCounts: Counts = counts ?? countsOf(match.participants);
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
@@ -123,44 +180,80 @@ export function MatchDetailPage() {
),
win_reason: winReason,
overall_comment: overall.trim() || null,
end_round: endRound,
objectives: countDict(finishCounts, "objectives"),
worlds: countDict(finishCounts, "worlds"),
...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 () => {
if (!id || !match) return;
setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
// но не в черновик, и второй участник увидел бы не то, что записалось.
if (draftTimer.current) {
clearTimeout(draftTimer.current);
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 {
await finish.mutateAsync({
matchId: id,
body: {
participants: [
...survivors,
...elim.map((uid) => ({
user_id: uid,
place: null,
eliminated: true,
comment: commentOf(uid),
})),
],
participants: resultRows(),
win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null,
expected_version: match.version,
},
@@ -188,7 +281,9 @@ export function MatchDetailPage() {
setComments(
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
);
setWinReason((match.win_reason ?? "objectives") as WinReason);
setCounts(countsOf(match.participants));
setWinReason((match.win_reason ?? null) as WinReason | null);
setEndRound(match.end_round ?? null);
setOverall(match.overall_comment ?? "");
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
setError(null);
@@ -199,36 +294,31 @@ export function MatchDetailPage() {
setEditing(false);
setBlocks(null);
setComments(null);
setCounts(null);
setError(null);
};
const submitEdit = async () => {
if (!id || !match) return;
setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
const wasRandom = Object.fromEntries(
match.participants.map((p) => [p.user_id, p.was_random]),
);
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
user_id: uid,
faction_id: editFactions[uid],
place,
eliminated,
was_random: wasRandom[uid] ?? false,
comment: commentOf(uid),
});
let place = 1;
const survivors = finishBlocks.flatMap((ids) => {
const rows = ids.map((uid) => rowOf(uid, place, false));
place += ids.length; // competition ranking: ничья съедает следующие места
return rows;
});
try {
await updateMatch.mutateAsync({
matchId: id,
body: {
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
participants: resultRows().map((r) => ({
...r,
faction_id: editFactions[r.user_id],
was_random: wasRandom[r.user_id] ?? false,
})),
win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null,
expected_version: match.version,
},
@@ -276,6 +366,13 @@ export function MatchDetailPage() {
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
];
const placeHint = (
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы
разделить место (ничья). Цели и миры — на конец партии.
</p>
);
return (
<div>
<div className="card">
@@ -294,6 +391,9 @@ export function MatchDetailPage() {
{!inProgress && (
<div className="small" style={{ marginTop: 4 }}>
Победа: <b>{winReasonLabel(match.win_reason)}</b>
{match.end_round != null && (
<span className="muted"> · конец в {match.end_round}-м раунде из {match.max_rounds}</span>
)}
</div>
)}
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
@@ -304,20 +404,16 @@ export function MatchDetailPage() {
<>
<div className="card">
<h3>Правка результатов</h3>
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
{placeHint}
<PlaceEditor
players={match.participants}
blocks={finishBlocks}
eliminated={elim}
comments={finishComments}
onChange={(b, e) => {
setBlocks(b);
setElim(e);
}}
counts={finishCounts}
onChange={applyLayout}
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
onCount={applyCount}
/>
</div>
@@ -345,16 +441,14 @@ export function MatchDetailPage() {
</div>
</div>
<div className="card">
<h3>Причина победы</h3>
<PickerSelect
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
options={REASON_OPTIONS}
placeholder="— причина —"
renderOption={(o) => o.label}
onPick={(o) => setWinReason(o.id)}
<MatchOutcomeFields
winReason={winReason}
onReason={setWinReason}
endRound={endRound}
onEndRound={setEndRound}
maxRounds={match.max_rounds}
warnings={warnings()}
/>
</div>
<div className="card">
<h3>О партии</h3>
@@ -413,6 +507,11 @@ export function MatchDetailPage() {
<span className="muted small">· {p.faction_name}</span>
{p.was_random && <span className="badge">🎲</span>}
</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>}
</div>
))}
@@ -448,10 +547,7 @@ export function MatchDetailPage() {
<>
<div className="card">
<h3>Места</h3>
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
{placeHint}
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
<p className="small" style={{ color: "var(--accent-2)" }}>
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
@@ -464,10 +560,10 @@ export function MatchDetailPage() {
blocks={finishBlocks}
eliminated={elim}
comments={finishComments}
counts={finishCounts}
onChange={(b, e) => {
setBlocks(b);
setElim(e);
queueDraft(draftOf({ blocks: b, eliminated: e }));
const reason = applyLayout(b, e);
queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason }));
}}
onComment={(uid, text) => {
const next = { ...finishComments, [uid]: text };
@@ -480,23 +576,33 @@ export function MatchDetailPage() {
}),
);
}}
/>
</div>
<div className="card">
<h3>Причина победы</h3>
<PickerSelect
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 }));
onCount={(uid, field, value) => {
const next = applyCount(uid, field, value);
queueDraft(
draftOf({
objectives: countDict(next, "objectives"),
worlds: countDict(next, "worlds"),
}),
);
}}
/>
</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">
<h3>О партии</h3>
<textarea
+4 -4
View File
@@ -6,7 +6,9 @@ import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types";
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 {
return v == null ? "—" : `${Math.round(v * 100)}%`;
@@ -50,7 +52,6 @@ export function OverallStatsPage() {
<span className="nick">{e.nickname}</span>
</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 lb-score" + (isProvisional || e.score == null ? " provisional" : "")}>
{e.score ?? "—"}
@@ -70,9 +71,8 @@ export function OverallStatsPage() {
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
<span>Игрок</span>
<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("score")}>Очки{arrow("score")}</button>
<button onClick={() => toggleSort("score")}>Рейтинг{arrow("score")}</button>
</div>
{entries.length === 0 ? (
<div className="muted small" style={{ paddingTop: 8 }}>
@@ -6,7 +6,7 @@ import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
// поэтому в прод-сборке вырезается тришейкингом (как и dev-вход).
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
export function AdminAccountsPage() {
+107 -6
View File
@@ -5,7 +5,15 @@ import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner";
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 {
useAdminDeleteAttachment,
useAdminFactions,
@@ -23,6 +31,8 @@ interface Row {
eliminated: boolean;
was_random: boolean;
comment: string;
objectives: number | null;
worlds: number | null;
}
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 [playedAt, setPlayedAt] = 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);
useEffect(() => {
@@ -51,19 +62,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: p.eliminated,
was_random: p.was_random,
comment: p.comment ?? "",
objectives: p.objectives ?? null,
worlds: p.worlds ?? null,
})),
);
setPlayedAt(match.played_at);
setOverall(match.overall_comment ?? "");
if (match.win_reason) setWinReason(match.win_reason);
setWinReason(match.win_reason ?? null);
setEndRound(match.end_round ?? null);
}
}, [match]);
const upd = (i: number, patch: Partial<Row>) =>
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
const upd = (i: number, patch: Partial<Row>) => {
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 () => {
setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
try {
await update.mutateAsync({
matchId,
@@ -71,6 +95,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
played_at: playedAt,
overall_comment: overall.trim() || null,
win_reason: winReason,
end_round: endRound,
participants: rows.map((r) => ({
user_id: r.user_id,
faction_id: r.faction_id,
@@ -78,6 +103,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: r.eliminated,
was_random: r.was_random,
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 (
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
<div
@@ -159,23 +202,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
выбыл
</label>
</div>
<div className="row">
<input
style={{ flex: 1 }}
placeholder="Комментарий об игроке"
value={r.comment}
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 className="field">
<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) => (
<option key={w.code} value={w.code}>
{w.label}
</option>
))}
</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 className="field">
@@ -10,9 +10,9 @@ import { useToast } from "../../context/ToastContext";
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
*
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда
* (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль
* (backend/app/routers/dev_admin.py, исключён из прод-образа). Этот модуль
* рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
* он не используется и вырезается тришейкингом — в прод/тест удаление недоступно.
* он не используется и вырезается тришейкингом — в проде удаление недоступно.
*/
export function DevDeleteAccountButton({
userId,
@@ -39,7 +39,7 @@ export function DevDeleteAccountButton({
<button
className="btn small"
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
// чтобы в прод-стили не попало ничего, связанного с удалением.
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
disabled={del.isPending}
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; }
/* Изменение рейтинга за партию в истории профиля */
.rating-delta { color: var(--text-muted); margin-top: 6px; }
.rating-up { color: var(--success); }
.rating-down { color: var(--danger); }
/* Формула в справке */
.formula {
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;
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 {
flex: none;
width: 30px; height: 30px;
+1 -1
View File
@@ -115,7 +115,7 @@
/* Таблица общей статистики (сортируемая, кликабельные строки) */
.st-row, .st-head {
display: grid;
grid-template-columns: 26px 1fr 40px 40px 48px 52px;
grid-template-columns: 26px 1fr 40px 48px 60px;
align-items: center;
gap: 8px;
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
# 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
# test -> docker compose prod-clone (port 8080)
# 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
# https://forbidden-stars.ru. test/prod expose themselves via an in-container tunnel.
# LOCAL_PUBLIC=vps opens an SSH tunnel to the VPS, exposing dev at
# https://forbidden-stars.ru. Prod exposes itself via an in-container tunnel.
#
# Run: .\run.ps1
# If blocked by policy: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
@@ -129,42 +128,18 @@ switch ($appEnv) {
if ($localPublic -eq "vps") {
Start-VpsTunnel 5173
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" {
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
}
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
}
}
+11 -16
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bash
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ Единый лаунчер dev / test. Что запускать — решает APP_ENV из корневого .env ║
# ║ Единый лаунчер dev. Что запускать — решает APP_ENV из корневого .env ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
# development → uvicorn --reload (бэк) + vite (фронт), нативно (Ctrl+C останавливает оба)
# test → docker compose прод-клон (порт 8080)
# production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi)
#
# LOCAL_PUBLIC=vps (только dev) поднимает SSH-туннель на VPS → https://forbidden-stars.ru
# test/prod выставляют себя сами через туннель-контейнер (см. docker-compose*.yml).
# LOCAL_PUBLIC=vps поднимает SSH-туннель на VPS → https://forbidden-stars.ru
# Прод выставляет себя сам через туннель-контейнер (см. docker-compose.yml).
#
# Запуск: ./run.sh (при необходимости: chmod +x run.sh)
set -euo pipefail
@@ -76,25 +75,21 @@ case "$app_env" in
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: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 )
;;
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)
echo "production лаунчером не запускается — прод обособлен."
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
exit 1
;;
*)
echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | test | production."
echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | production."
exit 1
;;
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
# over SSH, or (with -Target test) to the local test clone (docker-compose.test.yml).
# Step-by-step guide: deploy/backup/README.md
# over SSH. Step-by-step guide: deploy/backup/README.md
#
# .\scripts\fs-backup.ps1 status backup state on the Pi
# .\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 pull [-Snapshot <id>] [-Repo vps]
# 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):
# 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.
param(
[Parameter(Position = 0)]
[ValidateSet("status", "list", "now", "verify", "pull", "restore-test", "help")]
[ValidateSet("status", "list", "now", "verify", "pull", "help")]
[string]$Command = "help",
[string]$Snapshot = "latest",
[ValidateSet("local", "vps")]
[string]$Repo = "local",
[string]$Tag = "",
[string]$File = "",
[ValidateSet("pi", "test")]
[string]$Target = "pi"
[string]$Tag = ""
)
$ErrorActionPreference = "Stop"
# 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.
$root = Split-Path -Parent $PSScriptRoot
$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).
function Get-Setting([string]$name, [string]$default) {
@@ -56,7 +48,7 @@ function Fail([string]$msg) {
}
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" ""
@@ -77,29 +69,9 @@ function Invoke-Scp([string]$from, [string]$to) {
& scp -o ConnectTimeout=15 $from $to
}
function Invoke-TestCompose([string[]]$composeArgs) {
$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.
# Run fs-backup with arguments on the Pi; output goes to the console.
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 " "))
}
}
function Assert-LastExit([string]$what) {
@@ -126,19 +98,6 @@ function Invoke-Pull {
Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan
$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
# PowerShell pipes - they would corrupt it), then scp it and compare sha256.
$remote = "fs-export-$id.tar"
@@ -151,7 +110,6 @@ function Invoke-Pull {
} finally {
Invoke-Pi "rm -f ~/$remote"
}
}
$expected = ("$hashLine".Trim() -split "\s+")[0].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
}
# -------------------------------------------------------------------- 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
$prevEncoding = $null
try {
@@ -218,7 +148,6 @@ try {
}
"verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" }
"pull" { Invoke-Pull }
"restore-test" { Invoke-RestoreTest }
default { Show-Help }
}
} finally {
+4 -53
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup
# прода на Pi по SSH или (с --test) в локальный тест-клон (docker-compose.test.yml).
# На Windows удобнее scripts/fs-backup.ps1 — поведение то же. Инструкция: deploy/backup/README.md
# прода на Pi по SSH. На Windows удобнее scripts/fs-backup.ps1 — поведение то же.
# Инструкция: deploy/backup/README.md
#
# scripts/fs-backup.sh status состояние бэкапов на Pi
# scripts/fs-backup.sh list [vps] хронология снимков
@@ -9,9 +9,6 @@
# scripts/fs-backup.sh verify проверить целостность данных
# scripts/fs-backup.sh pull [<id>|latest] [--repo vps]
# скачать снимок в backups/ (сверка sha256)
# scripts/fs-backup.sh restore-test <файл.tar|.tar.gz>
# учебное восстановление в локальный тест-клон
# --test первым аргументом — status/list/now/verify/pull для локального тест-клона
#
# Настройки — из корневого .env (переменная окружения с тем же именем важнее):
# BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config)
@@ -19,8 +16,6 @@
set -euo pipefail
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; }
@@ -35,10 +30,7 @@ setting() { # setting <ключ> <по умолчанию>: окружение,
PI_SSH="$(setting BACKUP_PI_SSH "")"
# shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi
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; }
pi() { # pi <shell-команда>: выполнить на Pi в папке прода
@@ -46,22 +38,8 @@ pi() { # pi <shell-команда>: выполнить на Pi в папке п
ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1"
}
tc() { docker compose -f "$(native_path "$TEST_COMPOSE")" "$@"; }
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
fs() { # fs <аргументы fs-backup…>: выполнить fs-backup в контейнере backup на Pi
pi "docker compose exec -T backup fs-backup $*"
fi
}
cmd_pull() {
@@ -84,18 +62,10 @@ cmd_pull() {
partial="$local_file.part"
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"
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 не удался"; }
pi "rm -f ~/$remote"
fi
actual="$(sha256 "$partial")"
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 сверена)"
}
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}"
[ $# -eq 0 ] || shift
case "$cmd" in
@@ -133,6 +85,5 @@ case "$cmd" in
now) fs run "$@" ;;
verify) fs verify ;;
pull) cmd_pull "$@" ;;
restore-test) cmd_restore_test "$@" ;;
*) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' ;;
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' ;;
esac