diff --git a/.dockerignore b/.dockerignore index 54ede4e..7559c7c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example index 50bda92..4d7bdbb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes index 741036e..eb036e9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 1858c8a..dea409e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ venv/ data/ backend/dev.db* -# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod) +# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod) .env !.env.example diff --git a/README.md b/README.md index 74c4acd..827b9e4 100644 --- a/README.md +++ b/README.md @@ -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 -frontend/ React + Vite SPA -Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) -docker-compose.yml -.env.example +backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты +frontend/ React + Vite SPA +deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы) +scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК) +Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) +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 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). diff --git a/backend/alembic/versions/0014_rating_inputs.py b/backend/alembic/versions/0014_rating_inputs.py new file mode 100644 index 0000000..2c2b29c --- /dev/null +++ b/backend/alembic/versions/0014_rating_inputs.py @@ -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") diff --git a/backend/alembic/versions/0016_merge_rating_announcements.py b/backend/alembic/versions/0016_merge_rating_announcements.py new file mode 100644 index 0000000..5442441 --- /dev/null +++ b/backend/alembic/versions/0016_merge_rating_announcements.py @@ -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 diff --git a/backend/app/auth/admin_login.py b/backend/app/auth/admin_login.py index 7e1d0d9..4643d30 100644 --- a/backend/app/auth/admin_login.py +++ b/backend/app/auth/admin_login.py @@ -2,8 +2,9 @@ Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин» и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`). -Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный -барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого. +Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару, +20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики, +включая IP. """ from __future__ import annotations diff --git a/backend/app/auth/dev_stub.py b/backend/app/auth/dev_stub.py index 8dc37fc..0093c08 100644 --- a/backend/app/auth/dev_stub.py +++ b/backend/app/auth/dev_stub.py @@ -1,4 +1,4 @@ -"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production).""" +"""Dev-провайдер: вход без секрета по нику/идентификатору (только development).""" from __future__ import annotations from typing import Any diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index 2541851..159ca1a 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -1,7 +1,7 @@ """Общий вход: внешняя личность → пользователь → сессия. -Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом, -и dev-входом. +Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через +Telegram, /auth/login, /auth/register и dev-вход. """ from __future__ import annotations diff --git a/backend/app/auth/telegram.py b/backend/app/auth/telegram.py index 806f196..db01b1a 100644 --- a/backend/app/auth/telegram.py +++ b/backend/app/auth/telegram.py @@ -2,7 +2,7 @@ Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте). -Доступен и в dev, и в prod (в prod — единственный метод входа). +Доступен во всех окружениях — наряду со входом по логину и паролю. """ from __future__ import annotations diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py index 5a771a0..5ca8ae0 100644 --- a/backend/app/bootstrap.py +++ b/backend/app/bootstrap.py @@ -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) +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__": - bootstrap() + main() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0085fbe..970079f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import lru_cache from pathlib import Path -from pydantic import model_validator +from pydantic import field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково @@ -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 diff --git a/backend/app/core/ratelimit.py b/backend/app/core/ratelimit.py index 941b5de..4c45a71 100644 --- a/backend/app/core/ratelimit.py +++ b/backend/app/core/ratelimit.py @@ -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 / многих адресов diff --git a/backend/app/core/security.py b/backend/app/core/security.py index fd456da..5a7dfc8 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py deleted file mode 100644 index 90a009e..0000000 --- a/backend/app/db/init_db.py +++ /dev/null @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 52a56e0..7e78e7f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"): diff --git a/backend/app/models.py b/backend/app/models.py index 52d6582..83ad391 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -195,6 +195,12 @@ class Group(SQLModel, table=True): sa_column_kwargs={"onupdate": utcnow}, nullable=False, ) + # Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает + # значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает. + nine_rounds_rule: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="0"), + ) class GroupMember(SQLModel, table=True): @@ -275,7 +281,8 @@ class Match(SQLModel, table=True): Index("ix_matches_group_played", "group_id", "played_at"), CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"), CheckConstraint( - "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')", + "win_reason IS NULL OR win_reason IN " + "('objectives','worlds','plastic','resources','last_standing')", name="ck_match_win_reason", ), ) @@ -295,6 +302,14 @@ class Match(SQLModel, table=True): finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True)) + # Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится: + # он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds). + end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) + # Снимок Group.nine_rounds_rule на момент старта партии. + nine_rounds_rule: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="0"), + ) player_count: int = Field(sa_column=Column(Integer, nullable=False)) overall_comment: str | None = Field(sa_column=Column(Text, nullable=True)) created_by: int = Field( @@ -340,6 +355,10 @@ class MatchParticipant(SQLModel, table=True): eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) comment: str | None = Field(sa_column=Column(Text, nullable=True)) + # Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры + # на конец партии. У выбывшего миров 0. + objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) + worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) created_at: datetime = Field(default_factory=utcnow, nullable=False) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3c143ac..56c8811 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -13,9 +13,8 @@ from app.core.errors import InvalidCredentialsError, NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User -from app.routers.matches import attachment_read, build_match_read +from app.routers.matches import attachment_read, build_match_read, participant_inputs from app.schemas import api as s -from app.services.match_service import ParticipantInput from app.services import ( achievement_service, admin_service, @@ -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() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a9fd310..63b5bb6 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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, ) diff --git a/backend/app/routers/dev_admin.py b/backend/app/routers/dev_admin.py index 9e699f3..50e89af 100644 --- a/backend/app/routers/dev_admin.py +++ b/backend/app/routers/dev_admin.py @@ -1,9 +1,9 @@ """DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока. -Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер +Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так -возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только +возможность удаления не попадает в прод — там аккаунт можно только отключить (PATCH is_active). Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — diff --git a/backend/app/routers/dev_auth.py b/backend/app/routers/dev_auth.py index 7f36f2f..600e8ab 100644 --- a/backend/app/routers/dev_auth.py +++ b/backend/app/routers/dev_auth.py @@ -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 diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 1a9bc9d..010d43a 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -33,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail: owner_id=group.owner_id, my_role=member.role, expansion_ids=group_service.group_expansion_ids(session, group_id), + nine_rounds_rule=group.nine_rounds_rule, ) @@ -79,15 +80,19 @@ def get_group( @router.patch("/{group_id}", response_model=s.GroupDetail) -def rename_group( +def update_group( group_id: int, - body: s.GroupRename, + body: s.GroupUpdate, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.GroupDetail: + """Название и домашние правила группы; меняются только переданные поля.""" group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group = group_service.get_group(session, group_id) - group_service.rename_group(session, group, body.name) + if body.name is not None: + group_service.rename_group(session, group, body.name) + if body.nine_rounds_rule is not None: + group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule) return _detail(session, group_id, user.id) # type: ignore[arg-type] diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 8778b7c..ea6f970 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -22,10 +22,30 @@ from app.services import ( user_service, ) from app.services.match_service import FinishInput, ParticipantInput, RosterInput +from app.services.scoring import max_rounds router = APIRouter(prefix="/matches", tags=["matches"]) +def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None: + """Участники правки — общее для игроцкого и админского PATCH.""" + if body.participants is None: + return None + return [ + ParticipantInput( + user_id=p.user_id, + faction_id=p.faction_id, + place=p.place, + eliminated=p.eliminated, + was_random=p.was_random, + comment=p.comment, + objectives=p.objectives, + worlds=p.worlds, + ) + for p in body.participants + ] + + def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: """AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или @@ -56,6 +76,8 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, + objectives=p.objectives, + worlds=p.worlds, avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] ) for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] @@ -79,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False finished_at=iso_utc(match.finished_at), duration_minutes=match.duration_minutes, win_reason=match.win_reason, # type: ignore[arg-type] + end_round=match.end_round, + nine_rounds_rule=match.nine_rounds_rule, + max_rounds=max_rounds(match.player_count, match.nine_rounds_rule), player_count=match.player_count, overall_comment=match.overall_comment, created_by=match.created_by, @@ -157,6 +182,8 @@ def finish_match( eliminated=p.eliminated, comment=p.comment, faction_id=p.faction_id, + objectives=p.objectives, + worlds=p.worlds, ) for p in body.participants ] @@ -165,6 +192,7 @@ def finish_match( match, finish=finish, win_reason=body.win_reason, + end_round=body.end_round, overall_comment=body.overall_comment, overall_comment_set=("overall_comment" in body.model_fields_set), expected_version=body.expected_version, @@ -206,19 +234,6 @@ def update_match( ) -> s.MatchRead: match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) - participants = None - if body.participants is not None: - participants = [ - ParticipantInput( - user_id=p.user_id, - faction_id=p.faction_id, - place=p.place, - eliminated=p.eliminated, - was_random=p.was_random, - comment=p.comment, - ) - for p in body.participants - ] match = match_service.update_match( session, match, @@ -227,7 +242,9 @@ def update_match( overall_comment_set=("overall_comment" in body.model_fields_set), win_reason=body.win_reason, win_reason_set=("win_reason" in body.model_fields_set), - participants=participants, + end_round=body.end_round, + end_round_set=("end_round" in body.model_fields_set), + participants=participant_inputs(body), expected_version=body.expected_version, ) audit_service.record( @@ -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() diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 5ebc45d..d746302 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -2,11 +2,18 @@ from __future__ import annotations from datetime import date, 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): diff --git a/backend/app/seed/reference_data.py b/backend/app/seed/reference_data.py index 48c72a0..89d4611 100644 --- a/backend/app/seed/reference_data.py +++ b/backend/app/seed/reference_data.py @@ -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 diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index d9b2334..59b5bc9 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -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 (файл исключён из прод-образа). # ─── Группы ────────────────────────────────────────────────────────────────── diff --git a/backend/app/services/group_service.py b/backend/app/services/group_service.py index 7c9f3df..8cd2151 100644 --- a/backend/app/services/group_service.py +++ b/backend/app/services/group_service.py @@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group: return group +def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group: + """Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены: + уже начатые хранят свой снимок (Match.nine_rounds_rule).""" + group.nine_rounds_rule = enabled + session.add(group) + session.commit() + session.refresh(group) + return group + + def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group: valid = set(_valid_non_base_expansion_ids(session, expansion_ids)) current = session.exec( diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index e016aca..d51953c 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -19,9 +19,12 @@ from app.core.errors import ( from app.core.timeutil import app_today, iso_utc, utcnow from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.services import group_service +from app.services.scoring import EXTENDED_ROUNDS, max_rounds MAX_MATCH_PLAYERS = 6 -WIN_REASONS = ("objectives", "worlds", "plastic", "resources") +LAST_STANDING = "last_standing" +WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING) +MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API) @dataclass @@ -42,11 +45,13 @@ class FinishInput: eliminated: bool = False comment: str | None = None faction_id: int | None = None # опц. смена фракции при завершении + objectives: int | None = None + worlds: int | None = None @dataclass class ParticipantInput: - """Полный участник (для правки завершённой партии админом).""" + """Полный участник (для правки результатов завершённой партии).""" user_id: int faction_id: int @@ -54,6 +59,8 @@ class ParticipantInput: eliminated: bool = False was_random: bool = False comment: str | None = None + objectives: int | None = None + worlds: int | None = None def round_to_30(minutes: float) -> int: @@ -154,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc] +# ─── Итоги партии для рейтинга ──────────────────────────────────────────────── +# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между +# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ. + +def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None: + if end_round is None: + return + rmax = max_rounds(player_count, nine_rounds_rule) + if not 1 <= end_round <= rmax: + raise ValidationError(f"Раунд окончания — от 1 до {rmax}.") + + +def _worlds_of(eliminated: bool, worlds: int | None) -> int | None: + """У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие.""" + if not eliminated: + return worlds + if worlds: + raise ValidationError("У выбывшего игрока не может быть миров.") + return 0 + + +def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None: + """Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22). + + Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама, + сервер лишь не пропускает расхождение.""" + alone = sum(1 for e in eliminated if not e) == 1 + if alone and win_reason != LAST_STANDING: + raise ValidationError( + "Остался один невыбывший игрок — причина победы «последний выживший»." + ) + if not alone and win_reason == LAST_STANDING: + raise ValidationError( + "«Последний выживший» возможен, только когда все, кроме победителя, выбыли." + ) + + def _group_member_ids(session: Session, group_id: int) -> set[int]: return { m.user_id @@ -208,12 +252,15 @@ def create_match( ) now = utcnow() + group = group_service.get_group(session, group_id) match = Match( group_id=group_id, status="in_progress", played_at=app_today(), # дата игры — в поясе приложения (+3) started_at=now, player_count=len(roster), + # Снимок: смена настройки группы потом не переписывает лимит раундов этой партии. + nine_rounds_rule=group.nine_rounds_rule, created_by=creator.id, # type: ignore[arg-type] ) session.add(match) @@ -242,6 +289,7 @@ def finish_match( *, finish: list[FinishInput], win_reason: str, + end_round: int | None = None, overall_comment: str | None = None, overall_comment_set: bool = False, expected_version: str | None = None, @@ -251,6 +299,7 @@ def finish_match( raise ConflictError("Партия уже завершена.") if win_reason not in WIN_REASONS: raise ValidationError("Укажите корректную причину победы.") + _check_end_round(end_round, match.player_count, match.nine_rounds_rule) existing = { p.user_id: p @@ -273,12 +322,16 @@ def finish_match( raise FactionNotAvailableError() places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish]) + _check_last_standing(win_reason, [f.eliminated for f in finish]) + worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish} for f in finish: p = existing[f.user_id] p.place = places[f.user_id] p.eliminated = f.eliminated p.comment = f.comment or None + p.objectives = f.objectives + p.worlds = worlds[f.user_id] if f.faction_id is not None: p.faction_id = f.faction_id session.add(p) @@ -294,6 +347,7 @@ def finish_match( match.duration_minutes = round_to_30(elapsed_min) match.status = "finished" match.win_reason = win_reason + match.end_round = end_round if overall_comment_set: match.overall_comment = overall_comment or None @@ -312,22 +366,44 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None return session.get(MatchFinishDraft, match_id) +def _draft_counts(value: object) -> dict[str, int]: + """Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают.""" + if not isinstance(value, dict): + raise ValidationError("Некорректный черновик.") + out: dict[str, int] = {} + for k, v in value.items(): + if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT: + raise ValidationError("Некорректный черновик.") + out[str(k)] = v + return out + + def _validate_draft(session: Session, match: Match, data: dict) -> dict: """Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек - раскладывает их постепенно, и промежуточное состояние может быть любым.""" + раскладывает их постепенно, и промежуточное состояние может быть любым. По той же + причине не проверяется и правило «последнего выжившего».""" if not isinstance(data, dict): raise ValidationError("Некорректный черновик.") blocks = data.get("blocks") or [] eliminated = data.get("eliminated") or [] comments = data.get("comments") or {} win_reason = data.get("win_reason") + end_round = data.get("end_round") if not isinstance(blocks, list) or not isinstance(eliminated, list): raise ValidationError("Некорректный черновик.") if not isinstance(comments, dict): raise ValidationError("Некорректный черновик.") if win_reason is not None and win_reason not in WIN_REASONS: raise ValidationError("Некорректная причина победы.") + if end_round is not None and ( + isinstance(end_round, bool) + or not isinstance(end_round, int) + or not 1 <= end_round <= EXTENDED_ROUNDS + ): + raise ValidationError("Некорректный черновик.") + objectives = _draft_counts(data.get("objectives") or {}) + worlds = _draft_counts(data.get("worlds") or {}) participant_ids = { p.user_id @@ -354,6 +430,9 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict: "comments": {str(k): str(v) for k, v in comments.items()}, "win_reason": win_reason, "overall_comment": overall, + "end_round": end_round, + "objectives": objectives, + "worlds": worlds, } @@ -409,35 +488,41 @@ def update_match( overall_comment_set: bool = False, win_reason: str | None = None, win_reason_set: bool = False, + end_round: int | None = None, + end_round_set: bool = False, participants: list[ParticipantInput] | None = None, expected_version: str | None = None, ) -> Match: - """Правка партии: состав с местами, дата, комментарий, причина победы. + """Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд. - Результаты (места и причина победы) пишутся только в завершённую партию: иначе они - оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не - попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished'). - Дату и общий комментарий править можно и по ходу партии — двойственного состояния - они не создают.""" - results_touched = participants is not None or win_reason_set + Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию: + иначе они оседали бы в партии со статусом in_progress, которая остаётся + в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает + только status='finished'). Дату и общий комментарий править можно и по ходу + партии — двойственного состояния они не создают.""" + results_touched = participants is not None or win_reason_set or end_round_set if results_touched and match.status != "finished": raise ConflictError( "Результаты незавершённой партии нельзя править — сначала завершите её." ) assert_version(match, expected_version) - if played_at is not None: - match.played_at = played_at - if overall_comment_set: - match.overall_comment = overall_comment or None - if win_reason_set: - if win_reason is not None and win_reason not in WIN_REASONS: - raise ValidationError("Некорректная причина победы.") - match.win_reason = win_reason + if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS: + raise ValidationError("Некорректная причина победы.") + saved = session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + # Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется + # с тем, что уже записано. + new_reason = win_reason if win_reason_set else match.win_reason + new_end_round = end_round if end_round_set else match.end_round + new_count = len(participants) if participants is not None else match.player_count + if end_round_set or participants is not None: + _check_end_round(new_end_round, new_count, match.nine_rounds_rule) + + places: dict[int, int] = {} + worlds: dict[int, int | None] = {} if participants is not None: - saved = session.exec( - select(MatchParticipant).where(MatchParticipant.match_id == match.id) - ).all() # Что уже записано в партии, остаётся допустимым: состав группы и набор # дополнений с тех пор могли поменяться, но историю это чинить не мешает. _validate_roster_basics( @@ -451,6 +536,25 @@ def update_match( places = _resolve_finish_places( [(p.user_id, p.place, p.eliminated) for p in participants] ) + worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants} + if participants is not None or win_reason_set: + flags = ( + [p.eliminated for p in participants] + if participants is not None + else [p.eliminated for p in saved] + ) + _check_last_standing(new_reason, flags) + + if played_at is not None: + match.played_at = played_at + if overall_comment_set: + match.overall_comment = overall_comment or None + if win_reason_set: + match.win_reason = win_reason + if end_round_set: + match.end_round = end_round + + if participants is not None: for old in saved: session.delete(old) session.flush() @@ -464,6 +568,8 @@ def update_match( eliminated=p.eliminated, was_random=p.was_random, comment=p.comment or None, + objectives=p.objectives, + worlds=worlds[p.user_id], ) ) match.player_count = len(participants) diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index 2dad33c..d15f9f2 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -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: diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index 705bf86..f347f5b 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -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), diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index d77987e..8e495c7 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -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" - ) - ).one() - last_at = str(last_played) if last_played else None + 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] + ) + 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], diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 8763606..df6b47c 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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), } diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index 9d603f0..53f7997 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -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. diff --git a/backend/tests/test_admin_password_reset.py b/backend/tests/test_admin_password_reset.py new file mode 100644 index 0000000..e36e186 --- /dev/null +++ b/backend/tests/test_admin_password_reset.py @@ -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) diff --git a/backend/tests/test_api_hardening.py b/backend/tests/test_api_hardening.py index 4de3185..d27bb3e 100644 --- a/backend/tests/test_api_hardening.py +++ b/backend/tests/test_api_hardening.py @@ -1,4 +1,4 @@ -"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6).""" +"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6).""" from __future__ import annotations from fastapi.testclient import TestClient diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7079c1b..af2e252 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -23,14 +23,12 @@ def test_enabled_methods_by_env(monkeypatch): monkeypatch.setattr(settings, "app_env", "development") assert set(enabled_methods()) == {"password", "telegram", "stub"} - monkeypatch.setattr(settings, "app_env", "test") - assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub monkeypatch.setattr(settings, "app_env", "production") assert enabled_methods() == ["password", "telegram"] # prod → без stub def test_env_flags_and_db_path(monkeypatch): - """dev → файл дева; test и prod → том /data (общая ветвь is_development).""" + """dev → файл дева; prod → том /data.""" from app.core.config import settings monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db") @@ -38,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch): monkeypatch.setattr(settings, "app_env", "development") assert settings.is_development and settings.database_url == "sqlite:///dev.db" - monkeypatch.setattr(settings, "app_env", "test") - assert settings.is_test and settings.database_url == "sqlite:////data/prod.db" monkeypatch.setattr(settings, "app_env", "production") assert settings.is_production and settings.database_url == "sqlite:////data/prod.db" diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py index 8c73066..7175fc4 100644 --- a/backend/tests/test_config_security.py +++ b/backend/tests/test_config_security.py @@ -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 diff --git a/backend/tests/test_events_payload.py b/backend/tests/test_events_payload.py index 63bd3db..72a6198 100644 --- a/backend/tests/test_events_payload.py +++ b/backend/tests/test_events_payload.py @@ -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) - ).json()["id"] + + 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") diff --git a/backend/tests/test_group_rating_members.py b/backend/tests/test_group_rating_members.py new file mode 100644 index 0000000..b08edec --- /dev/null +++ b/backend/tests/test_group_rating_members.py @@ -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)] diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 03b02fb..69015fd 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -322,9 +322,10 @@ def test_history_excludes_matches_without_the_player(client: TestClient, engine) def test_history_best_mode_picks_highest_points(client: TestClient, engine): - """Режим best берёт партию с максимальными League Points, а не самую свежую. + """Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую. - Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0.""" + Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место + в дуэли — отнимает.""" me = login(client, "Лучший") gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В") diff --git a/backend/tests/test_rating_examples.py b/backend/tests/test_rating_examples.py new file mode 100644 index 0000000..3885dd3 --- /dev/null +++ b/backend/tests/test_rating_examples.py @@ -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) diff --git a/backend/tests/test_rating_inputs.py b/backend/tests/test_rating_inputs.py new file mode 100644 index 0000000..29ea915 --- /dev/null +++ b/backend/tests/test_rating_inputs.py @@ -0,0 +1,226 @@ +"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов, +причина «последний выживший». Сервер проверяет диапазоны и явные противоречия.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + + +def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]: + """Группа со всеми дополнениями и players участниками (первый — вошедший).""" + me = login(client, "Хозяин") + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)] + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + return gid, uids, fids + + +def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict: + r = start_match( + client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)] + ) + assert r.status_code == 200, r.text + return r.json() + + +def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra): + body = {"participants": participants, "win_reason": win_reason, **extra} + return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client)) + + +def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict: + r = client.patch( + f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + return r.json() + + +def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + assert match["max_rounds"] == 8 and match["end_round"] is None + + r = _finish( + client, match["id"], + [ + {"user_id": a, "place": 1, "objectives": 2, "worlds": 5}, + {"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся + ], + "objectives", end_round=6, + ) + assert r.status_code == 200, r.text + data = r.json() + assert data["end_round"] == 6 + parts = {p["user_id"]: p for p in data["participants"]} + assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5) + assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None) + + listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"] + assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1} + + +def test_end_round_limited_by_max_rounds(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}] + assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422 + assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422 + assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200 + + +def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine): + gid, uids, fids = _group(client, engine, 5) + assert _set_rule(client, gid, True)["nine_rounds_rule"] is True + assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто + + five = _start(client, gid, uids, fids) + four = _start(client, gid, uids[:4], fids) + assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9) + assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5 + + # Смена настройки группы не переписывает уже начатую партию. + _set_rule(client, gid, False) + assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9 + + rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)] + assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200 + rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])] + assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422 + + +def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine): + gid, (a, b, c), fids = _group(client, engine, 3) + match = _start(client, gid, [a, b, c], fids) + alone = [ + {"user_id": a, "place": 1}, + {"user_id": b, "eliminated": True}, + {"user_id": c, "eliminated": True}, + ] + r = _finish(client, match["id"], alone, "objectives") + assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"] + + two = [ + {"user_id": a, "place": 1}, + {"user_id": b, "place": 2}, + {"user_id": c, "eliminated": True}, + ] + assert _finish(client, match["id"], two, "last_standing").status_code == 422 + + ok = _finish(client, match["id"], alone, "last_standing") + assert ok.status_code == 200, ok.text + assert ok.json()["win_reason"] == "last_standing" + + +def test_eliminated_player_has_no_worlds(client: TestClient, engine): + gid, (a, b, c), fids = _group(client, engine, 3) + match = _start(client, gid, [a, b, c], fids) + rows = [ + {"user_id": a, "place": 1, "worlds": 7}, + {"user_id": b, "place": 2, "worlds": 4}, + {"user_id": c, "eliminated": True, "worlds": 2}, + ] + assert _finish(client, match["id"], rows, "objectives").status_code == 422 + + rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны + r = _finish(client, match["id"], rows, "objectives") + assert r.status_code == 200, r.text + parts = {p["user_id"]: p for p in r.json()["participants"]} + assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1) + + +def test_negative_counts_rejected(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}] + assert _finish(client, match["id"], rows, "objectives").status_code == 422 + + +def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict: + match = _start(client, gid, uids, fids) + rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)] + r = _finish(client, match["id"], rows, "objectives") + assert r.status_code == 200, r.text + return r.json() + + +def _edit_rows(match: dict, **by_user) -> list[dict]: + rows = [] + for p in match["participants"]: + row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]} + row.update(by_user.get(str(p["user_id"]), {})) + rows.append(row) + return rows + + +def test_edit_checks_last_standing_and_round(client: TestClient, engine): + gid, (a, b, c), fids = _group(client, engine, 3) + match = _finished(client, gid, [a, b, c], fids) + mid = match["id"] + + def patch(body: dict): + return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client)) + + elim = {"place": None, "eliminated": True} + rows = _edit_rows(match, **{str(b): elim, str(c): elim}) + assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422 + # Причина не передана — сверяется с записанной («по целям»): тоже противоречие. + assert patch({"participants": rows}).status_code == 422 + r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5}) + assert r.status_code == 200, r.text + assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5) + + # Одна только причина: при одном выжившем вернуть «по целям» нельзя. + assert patch({"win_reason": "objectives"}).status_code == 422 + # Один только раунд: в пределах лимита — можно, за лимитом — нет. + assert patch({"end_round": 9}).status_code == 422 + assert patch({"end_round": None}).json()["end_round"] is None + + +def test_admin_edit_saves_counts(client: TestClient, engine, make_admin): + gid, (a, b), fids = _group(client, engine, 2) + match = _finished(client, gid, [a, b], fids) + make_admin("admin", "secret123") + assert client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ).status_code == 200 + rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}}) + r = client.patch( + f"/api/admin/matches/{match['id']}", + json={"participants": rows, "win_reason": "objectives", "end_round": 7}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + parts = {p["user_id"]: p for p in r.json()["participants"]} + assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7) + + +def test_draft_keeps_round_and_counts(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + body = { + "blocks": [[a], [b]], + "win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется + "end_round": 4, + "objectives": {str(a): 2}, + "worlds": {str(a): 5, str(b): 3}, + } + r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"] + assert data["end_round"] == 4 + assert data["objectives"] == {str(a): 2} + assert data["worlds"] == {str(a): 5, str(b): 3} + + bad = client.put( + f"/api/matches/{match['id']}/finish-draft", + json={"worlds": {str(a): -3}}, + headers=csrf_headers(client), + ) + assert bad.status_code == 422 diff --git a/backend/tests/test_rating_stats.py b/backend/tests/test_rating_stats.py new file mode 100644 index 0000000..5e0f18f --- /dev/null +++ b/backend/tests/test_rating_stats.py @@ -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 diff --git a/backend/tests/test_scoring_smoothing.py b/backend/tests/test_scoring_smoothing.py deleted file mode 100644 index af06321..0000000 --- a/backend/tests/test_scoring_smoothing.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Сглаживание рейтинга: score = (C·m + сумма очков) / (C + игр) × 100, C=10, m=0.5.""" -from __future__ import annotations - -from fastapi.testclient import TestClient - -from tests.conftest import add_group_member, create_finished_match, csrf_headers, login - - -def test_leaderboard_and_profile_score_are_smoothed(client: TestClient, engine): - me = login(client, "Хозяин") - gid = client.post( - "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) - ).json()["id"] - b = add_group_member(engine, gid, "Гость") - factions = client.get(f"/api/groups/{gid}/factions").json() - f1, f2 = factions[0]["id"], factions[1]["id"] - - create_finished_match( - client, - gid, - [ - {"user_id": me["id"], "faction_id": f1, "place": 1}, - {"user_id": b, "faction_id": f2, "place": 2}, - ], - ) - - board = client.get("/api/stats/leaderboard").json() - by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]} - # Победитель: 1 очко за партию → (10·0.5 + 1) / (10 + 1) × 100 = 54.5. - # Проигравший: 0 очков → (10·0.5 + 0) / 11 × 100 = 45.5. - assert by_id[me["id"]]["score"] == 54.5 - assert by_id[b]["score"] == 45.5 - # 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст. - assert board["entries"] == [] - assert board["min_games"] == 10 - - # Профиль показывает тот же сглаженный рейтинг, что и топ. - prof = client.get("/api/users/me/stats").json() - assert prof["overall"]["score"] == 54.5 diff --git a/backend/tests/test_stats_passes.py b/backend/tests/test_stats_passes.py index 6839358..67cf555 100644 --- a/backend/tests/test_stats_passes.py +++ b/backend/tests/test_stats_passes.py @@ -1,9 +1,9 @@ -"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз.""" +"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз.""" from __future__ import annotations from fastapi.testclient import TestClient -from sqlalchemy import event +from app.services import stats_service from tests.conftest import add_group_member, create_finished_match, csrf_headers, login @@ -31,10 +31,7 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic def test_profile_numbers_match_leaderboard(client: TestClient, engine): - """Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать. - - Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и - scoring.smoothed_score); этот тест ловит их расхождение.""" + """Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать.""" me, gid, p2 = _group_with_matches(client, engine, games=4) profile = client.get("/api/users/me/stats").json()["overall"] @@ -47,33 +44,25 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine): assert profile[field] == entry[field], field -def test_home_does_not_repeat_scored_cte(client: TestClient, engine): - """Главная делает не больше двух проходов по SCORED_CTE. - - Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой - проверки оптимизация тихо отъедет назад при следующей правке витрин.""" +def test_home_loads_history_once(client: TestClient, engine, monkeypatch): + """Главная грузит историю партий один раз: топ, профиль и итог активной группы + считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин.""" me, gid, p2 = _group_with_matches(client, engine, games=2) client.put( "/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client) ) - seen: list[str] = [] + calls: list[int] = [] + 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: - r = client.get("/api/home") - assert r.status_code == 200, r.text - finally: - event.remove(engine, "before_cursor_execute", before_execute) - - # Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не - # для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо - # ловить (сменился путь, переименован CTE). - assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}" + monkeypatch.setattr(stats_service, "load_history", spy) + r = client.get("/api/home") + assert r.status_code == 200, r.text + assert len(calls) == 1 # Главная всё ещё показывает и профиль, и блок активной группы. body = r.json() assert body["profile"]["overall"]["games"] == 2 diff --git a/deploy/README.md b/deploy/README.md index 57e1b54..6388984 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,6 @@ # Публикация: домены, VPS, туннели -Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет +Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет (CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые приложение само открывает к VPS. @@ -11,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`. diff --git a/deploy/backup/Dockerfile b/deploy/backup/Dockerfile index 27bfb70..9474703 100644 --- a/deploy/backup/Dockerfile +++ b/deploy/backup/Dockerfile @@ -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. diff --git a/deploy/backup/README.md b/deploy/backup/README.md index 21acf57..483de6d 100644 --- a/deploy/backup/README.md +++ b/deploy/backup/README.md @@ -16,7 +16,7 @@ 4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК 5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi 6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК -7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК +7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК 8. [Восстановление прода](#8-восстановление-прода) — Pi 9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi 10. [Повседневные действия](#10-повседневные-действия) @@ -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 `, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)). +2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК: + `.\scripts\fs-backup.ps1 pull -Snapshot `, затем [шаг 7](#шаг-7-проверка-скачанного-архива). + Заодно у вас останется копия этого снимка вне Pi. 3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»: @@ -737,30 +737,51 @@ ``` 3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`). - В журнале бэкапа это нормально (`docker compose logs backup`): + Контейнер `backup` стартует одновременно с `app`, ждёт, пока приложение ответит (до + 10 минут), и пробует сделать первый бэкап. В его журнале (`docker compose logs backup`) + нормально увидеть одно из трёх: - ``` - В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными. - Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю. - ``` + - если приложение так и не поднялось и БД нет: - Это защита: пустой новый Pi не перезапишет историю на VPS. + ``` + ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось? + Первый бэкап не удался — следующая попытка по расписанию. + ``` -4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий): + - если БД есть, но без таблиц (миграции ещё не прошли): + + ``` + Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches. + ``` + + - если БД уже была: + + ``` + В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными. + Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю. + ``` + + Это защита: пустой новый Pi не перезапишет историю на 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 --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 `); если повторяется — `fs-backup verify` на Pi | --- @@ -906,9 +965,15 @@ docker volume rm <имя тома> | `recover` | разбор прерванного восстановления | | `export [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` | | `restic <аргументы>` | любая команда 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 --repo vps`, `restore-test <архив>`, `--test` первым аргументом. +`pull --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@` | | `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 diff --git a/deploy/backup/entrypoint.sh b/deploy/backup/entrypoint.sh index e1028ca..a6e2440 100644 --- a/deploy/backup/entrypoint.sh +++ b/deploy/backup/entrypoint.sh @@ -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 diff --git a/deploy/backup/fs-backup.sh b/deploy/backup/fs-backup.sh index 0757614..31dcc32 100644 --- a/deploy/backup/fs-backup.sh +++ b/deploy/backup/fs-backup.sh @@ -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/&/\&/g' -e 's//\>/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 — одно сообщение в один чат + 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 — во все чаты владельца + tg_enabled || return 0 + for _chat in $(tg_chat_ids); do + tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено." + done +} + +repo_summary() { # repo_summary → «снимков 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="✅ Бэкап $_when +$_db" + else + _text="❌ Бэкап не удался $_when +$(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)" + [ "$_players" = "?" ] || _text="$_text +$_db" + fi + for _item in $_ok_repos; do + _text="$_text +• ${_item%%:*}: снимок ${_item#*:} · $(repo_summary "${_item%%:*}")" + done + for _repo in $_failed; do + _text="$_text +• $_repo: ошибка" + done + [ "$1" -eq 0 ] || _text="$_text +Подробности: docker compose logs backup" + tg_send "$_text" +} + +notify_verify() { # notify_verify <код выхода> + tg_enabled || return 0 + _when="$(date '+%d.%m %H:%M')" + if [ "$1" -eq 0 ]; then + _text="🔍 Проверка данных: OK $_when" + else + _text="❌ Проверка данных не прошла $_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 +Подробности: docker compose logs backup" + 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' +Бэкапы Forbidden Stars +/backups — хранящиеся снимки по репозиториям +/status — последние бэкапы и проверки, размеры +Отчёт о каждом бэкапе и проверке приходит сюда сам. +EOF +} + +tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних + [ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; } + setup_ssh + for _repo in $(repos); do + echo "$_repo · $(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 | .[] | + "\(.time[5:16] | sub("T"; " ")) игроков \(tagval("players:")), партий \(tagval("matches:"))" + + (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)' + else + echo "репозиторий недоступен или занят — повторите позже" + fi + echo + done +} + +tg_allowed() { # tg_allowed — чат из белого списка владельца + for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done + return 1 +} + +tg_reply() { # tg_reply <текст команды> + # Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота. + case "$2" in + /backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;; + /status*) _reply="
$(cmd_status 2>&1 | head -n 60 | tg_escape)
" ;; + *) _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 [--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 <аргументы> произвольная команда 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 diff --git a/deploy/pi/README.md b/deploy/pi/README.md index a0b0ea0..9c67624 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -2,12 +2,13 @@ На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл ключа туннеля не нужны: -- образы (`app` + `tunnel`) тянутся из Gitea-реестра (`pull_policy: always`); +- образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`); - приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64). -`docker compose up` поднимает два контейнера: `app` (FastAPI+SPA, портов на хост нет) и -`tunnel` (`ssh -R 9000:app:8000` к VPS). Публичная точка — VPS, домен `forbiddenstars.ru` -(Pi за CGNAT — туннель стучится наружу сам). +`docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет), +`tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic, +см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель +стучится наружу сам). --- @@ -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, + «Лимиты памяти». diff --git a/deploy/tunnel/tunnel.sh b/deploy/tunnel/tunnel.sh index 78df777..7c99650 100644 --- a/deploy/tunnel/tunnel.sh +++ b/deploy/tunnel/tunnel.sh @@ -10,7 +10,7 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}" UPSTREAM="${UPSTREAM:-app:8000}" # Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env), -# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте). +# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте). mkdir -p /root/.ssh KEY=/root/.ssh/id_tunnel if [ -n "${TUNNEL_KEY_B64:-}" ]; then diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index c4f4f7f..42f06d9 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -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) НЕ кэшируем. Иначе браузер отдаёт старый diff --git a/deploy/vps/README.md b/deploy/vps/README.md index aae1a61..ecc4a94 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -1,11 +1,11 @@ # VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами -для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test). +для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev). ``` -forbiddenstars.ru → 127.0.0.1:9000 ← Pi (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), это ожидаемо. diff --git a/docker-compose.temp.yml b/docker-compose.temp.yml index 3baaf72..f949d8e 100644 --- a/docker-compose.temp.yml +++ b/docker-compose.temp.yml @@ -5,7 +5,7 @@ # # Отличия от docker-compose.yml (прод на Pi): # • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится; -# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК. +# • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК. # Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck). # # Запуск: docker compose -f docker-compose.temp.yml up -d --build diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index 2c96d6b..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,86 +0,0 @@ -# Локальный «клон прода» в контейнере — для проверки прод-сборки на своей машине -# (а не на Raspberry Pi). Тот же образ, что и прод, изолированные тома, единый .env. -# Портов на хост НЕТ: тест виден ТОЛЬКО снаружи на https://forbidden-stars.ru через -# сервис tunnel (контейнер ssh -R 9001:app:8000 на VPS). -# -# Запуск (обычно лаунчером при APP_ENV=test): docker compose -f docker-compose.test.yml up --build -d -# Остановить и стереть данные: docker compose -f docker-compose.test.yml down -v -services: - app: - build: . - image: forbidden-stars:test - container_name: forbidden-stars-test - restart: "no" - init: true - env_file: - - .env # единый .env (тот же, что у dev/prod); секреты не в git - environment: - # Окружение test: прод-клон, но отличимый от прода (см. config.is_test). - # Форсим здесь, чтобы значение не зависело от APP_ENV внутри .env. - APP_ENV: test - # Портов на хост НЕТ: тест доступен только изнутри сети compose; наружу — через - # сервис tunnel (ниже) на forbidden-stars.ru. Из LAN/localhost — недоступно. - volumes: - - db-data-test:/data # изолированные тестовые данные - - uploads-data-test:/data/uploads - - achievements-data-test:/data/achievements # определения ачивок (файлы) - healthcheck: - test: - - CMD - - python - - -c - - "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" - interval: 30s - timeout: 5s - retries: 3 - start_period: 40s - mem_limit: 512m - - # SSH reverse-туннель к VPS: forbidden-stars.ru (VPS:9001) -> app:8000 (по сети compose). - # Ключ — ./deploy/tunnel/id_tunnel (gitignore), pubkey в authorized_keys у tunnel@VPS. - # Внимание: слот 9001 общий с dev-туннелем (run.ps1); поднимай что-то одно за раз. - tunnel: - build: ./deploy/tunnel - image: forbidden-stars-tunnel:test - restart: unless-stopped - init: true - depends_on: - app: - condition: service_healthy - environment: - VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST} - VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel} - VPS_TUNNEL_PORT: "9001" # forbidden-stars.ru (dev/test) - UPSTREAM: app:8000 - volumes: - - ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro - mem_limit: 64m - - # Бэкапы тест-клона: тот же образ, что у прода (сборка локально), но ТОЛЬКО локальный - # репозиторий и без расписания. BACKUP_VPS_HOST принудительно пуст — тестовые данные - # никогда не попадут в прод-репозиторий на VPS. Учебное восстановление: - # .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar - backup: - build: ./deploy/backup - image: forbidden-stars-backup:test - restart: "no" - environment: - BACKUP_PASSWORD: ${BACKUP_PASSWORD:-test-contour-only} - BACKUP_HOSTNAME: fs-test - BACKUP_SCHEDULE: "" - BACKUP_VERIFY_SCHEDULE: "" - BACKUP_VPS_HOST: "" - BACKUP_COMPRESSION: ${BACKUP_COMPRESSION:-max} - TZ: ${BACKUP_TZ:-Europe/Moscow} - volumes: - - db-data-test:/fs-db - - uploads-data-test:/fs/uploads - - achievements-data-test:/fs/achievements - - backup-data-test:/backup - mem_limit: 384m - -volumes: - db-data-test: - uploads-data-test: - achievements-data-test: - backup-data-test: diff --git a/docker-compose.yml b/docker-compose.yml index 23697e6..6d2528d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 # живая БД (снимается консистентно) diff --git a/docs/rating/rating-system.md b/docs/rating/rating-system.md new file mode 100644 index 0000000..c1a738c --- /dev/null +++ b/docs/rating/rating-system.md @@ -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, «Калибровка»). diff --git a/docs/rating/simulate.py b/docs/rating/simulate.py new file mode 100644 index 0000000..8fa0546 --- /dev/null +++ b/docs/rating/simulate.py @@ -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() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ebb3606..ae88693 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; + return <>{children}; +} export function App() { return ( - + + + ); diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index eeaa086..239aaed 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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", + }); +} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index c93b259..705a381 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -377,8 +377,11 @@ export interface paths { delete?: never; options?: never; head?: never; - /** Rename Group */ - patch: operations["rename_group_api_groups__group_id__patch"]; + /** + * Update Group + * @description Название и домашние правила группы; меняются только переданные поля. + */ + patch: operations["update_group_api_groups__group_id__patch"]; trace?: never; }; "/api/groups/{group_id}/expansions": { @@ -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: { diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index edc08d7..54f4eff 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -30,8 +30,14 @@ function Row({ {/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только - подтверждённый рейтинг. */} -
+ подтверждённый рейтинг. Рейтинг общий, поэтому подтверждённость приходит + флагом: в группе «Ещё не играли» бывают и новички, и опытные игроки. */} +
{entry.score ?? "—"}
diff --git a/frontend/src/components/MatchHistory.tsx b/frontend/src/components/MatchHistory.tsx index 5d0b8f3..2e5bec5 100644 --- a/frontend/src/components/MatchHistory.tsx +++ b/frontend/src/components/MatchHistory.tsx @@ -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,29 +54,33 @@ export function MatchHistory({ return ( ); })} diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index 014a31e..23a5745 100644 --- a/frontend/src/components/MatchList.tsx +++ b/frontend/src/components/MatchList.tsx @@ -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
Партий пока нет.
; @@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
))} + {m.rating_delta != null && } ); })} diff --git a/frontend/src/components/MatchOutcomeFields.tsx b/frontend/src/components/MatchOutcomeFields.tsx new file mode 100644 index 0000000..347e31c --- /dev/null +++ b/frontend/src/components/MatchOutcomeFields.tsx @@ -0,0 +1,76 @@ +import { PickerSelect } from "./PickerSelect"; +import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; + +const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); +const NO_ROUND = 0; + +/** + * Итог партии в форме завершения и правки результатов: причина победы, раунд окончания + * и предупреждения о несогласованном вводе. «Последний выживший» не выбирается — + * пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors). + */ +export function MatchOutcomeFields({ + winReason, + onReason, + endRound, + onEndRound, + maxRounds, + warnings, +}: { + winReason: WinReason | null; + onReason: (reason: WinReason) => void; + endRound: number | null; + onEndRound: (round: number | null) => void; + maxRounds: number; + warnings: string[]; +}) { + const roundOptions = [ + { id: NO_ROUND, label: "не указан" }, + ...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })), + ]; + return ( +
+

Итог партии

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

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

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

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

+ +

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

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

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

diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index e7fe227..e93ce49 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -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 && ( <>
-

Очки игроков (рейтинг)

+

Рейтинг игроков

- Рейтинг считается только по завершённым партиям. Незавершённые и отменённые - в зачёт не идут. + Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает + с 1500. Разница в 400 пунктов означает шансы 10 к 1 в пользу более + сильного.

- За каждую партию игрок получает «очки за место» — они нормированы по числу игроков - за столом, поэтому победа за большим столом ценится выше, чем за маленьким: + Считаются только завершённые партии, по порядку их игры. Правка или удаление + старой партии пересчитывает и все последующие.

-
- очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1) +

+ Партия раскладывается на пары игроков. В каждой паре фактический результат (выше — + 1, поровну — 0.5, ниже — 0) сравнивается с ожидаемым по рейтингам до партии: +

+
ожидание = 1 / (1 + 10^((R соперника − R игрока) / 400))
+
+ ΔR = K × G / (N − 1) × Σ M × (результат − ожидание)

- где N — число игроков в партии. Первое место даёт 1.0, последнее — 0.0. -

-
    -
  • Если несколько игроков делят место (ничья) — они делят сумму очков поровну.
  • -
  • Выбывшие из партии считаются как последнее место.
  • -
-

- Итоговый рейтинг игрока — сглаженное среднее: к реальным партиям - «дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100): -

-
- рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100 -
-

- Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному - среднему — стабильные результаты на длинной дистанции ценятся выше короткой - удачной серии. + где N — число игроков, сумма — по всем соперникам. Поэтому победа над сильным + приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие + делят последнее место, но между собой не сравниваются: в этой партии все они + проиграли, так что пара двух выбывших рейтинг не двигает.

-

Пример (стол на 4 игроков)

+

От чего зависит изменение

+
    +
  • + K — скорость. У новичка 64, к 20-й партии плавно снижается до 16: новичок + быстро находит свой уровень, а рейтинг опытного игрока не скачет. +
  • +
  • + G — размер стола: 1 + 0.5 × (N − 2) / 4. В дуэли 1.0, вчетвером 1.25, + вшестером 1.5. +
  • +
  • + M — отрыв в паре, от 0.5 до 2. Обычная партия даёт 1. Больше, если: +
      +
    • + партия закончилась раньше — для пар с победителем; обычной считается конец + в предпоследнем раунде; +
    • +
    • разница целей велика относительно числа игроков;
    • +
    • разница миров велика относительно доли миров на игрока.
    • +
    +
  • +
  • + Тип победы — для пар с победителем, чем ближе партия, тем меньше: + по целям ×1, по мирам ×0.85, по пластику ×0.7, по ресурсам ×0.6, + последний выживший ×1. +
  • +
+

+ Раунд окончания, цели и миры вводить необязательно: пропущенное считается + обычным значением и множитель не меняет. «Последний выживший» ставится сам, когда + все соперники выбыли, — отрыв по целям тогда максимальный. Лимит раундов — 8, + с правилом группы «9 раундов» при 5–6 игроках — 9. +

+

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

+
+ +
+

Примеры (опытные игроки)

-
1-е место(4−1−0)/3 = 1.00 → 100
-
2-е место(4−2−0)/3 = 0.67 → 67
-
3-е место(4−3−0)/3 = 0.33 → 33
-
4-е место(4−4−0)/3 = 0.00 → 0
- Ничья за 1-е (двое)(4−1−0.5)/3 = 0.83 каждому + Дуэль 1600 и 1400: победил сильный + +3.8 / −3.8 +
+
+ Дуэль 1600 и 1400: победил слабый + +12.2 / −12.2 +
+
+ Равные, победа по целям без деталей + +8.0 +
+
+ Равные, победа по мирам без деталей + +6.8 +
+
+ Разгром на 3-м раунде (цели 2:0, миры 8:2) + +16.0 +
+
+ 1-е место за столом на 5 равных + +11.0 +
+
+ Новичок обыграл равного опытного + +32 / −8
+

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

+
+ +
+

Один рейтинг на всё приложение

+

+ Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый + в общем топе, в профиле и на странице группы. На странице группы по партиям этой + группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус + «Новичок» там общие. +

@@ -91,6 +160,7 @@ export function HelpPage() {
  • Винрейт (WR) — доля партий, в которых игрок занял 1-е место.
  • Среднее место — среднее по завершённым партиям.
  • +
  • Лучшая партия в профиле — та, что принесла больше всего рейтинга.
  • При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту и затем по нику. diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 4a0fc31..304c6bc 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { MatchMedia } from "../components/MatchMedia"; +import { MatchOutcomeFields } from "../components/MatchOutcomeFields"; import { PickerSelect } from "../components/PickerSelect"; -import { PlaceEditor } from "../components/PlaceEditor"; +import { type CountField, type Counts, PlaceEditor } from "../components/PlaceEditor"; import { PlayerLink } from "../components/PlayerLink"; import { Spinner } from "../components/Spinner"; +import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings"; import { formatDate, formatDuration, formatTime } from "../domain/format"; -import type { MatchFinishDraftData } from "../domain/types"; -import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; +import type { MatchFinishDraftData, MatchRead } from "../domain/types"; +import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons"; import { useToast } from "../context/ToastContext"; import { useMe } from "../hooks/auth"; import { @@ -24,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 => + 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(null); const [elim, setElim] = useState([]); const [comments, setComments] = useState | null>(null); - const [winReason, setWinReason] = useState("objectives"); + const [counts, setCounts] = useState(null); + // null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors). + const [winReason, setWinReason] = useState("objectives"); + const [endRound, setEndRound] = useState(null); const [overall, setOverall] = useState(""); const [error, setError] = useState(null); const [confirmRemove, setConfirmRemove] = useState(false); @@ -86,23 +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 ; - if (!match) return
    Партия не найдена.
    ; + if (!match) { + // Из истории чужого профиля можно попасть в партию группы, где зритель не состоит: + // сервер отдаёт 403, и «не найдена» здесь вводила бы в заблуждение. + if (loadError instanceof ApiError && loadError.code === "NOT_GROUP_MEMBER") { + return ( +
    + Партия сыграна в группе, в которой вы не состоите, — открыть её могут только + участники группы. +
    + ); + } + return
    Партия не найдена.
    ; + } - 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 = 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 = ( +

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

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

    {match.overall_comment}

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

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

    -

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

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

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

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

    О партии

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

    Места

    -

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

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

    Результаты заполняет также {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() { }), ); }} - /> -

    - -
    -

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

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

    О партии