Compare commits
50
Commits
551032d66e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a5cf4dff | ||
|
|
0b33b442e9 | ||
|
|
d12afe62d0 | ||
|
|
30270916a4 | ||
|
|
2f0b22ccd4 | ||
|
|
89cbb81be7 | ||
|
|
f5f8dc03ce | ||
|
|
ec1e94119c | ||
|
|
8d4f7a95df | ||
|
|
5b89dc9d2f | ||
|
|
00bd3c72b6 | ||
|
|
f7f74fee8c | ||
|
|
643a9f1795 | ||
|
|
709b864c60 | ||
|
|
03834c6855 | ||
|
|
0ee7cd1274 | ||
|
|
1bc3940ee8 | ||
|
|
164db495b4 | ||
|
|
cff48f7cc7 | ||
|
|
bb5f2a1121 | ||
|
|
63a2cddd5e | ||
|
|
5035ee41e6 | ||
|
|
6c35cb04d4 | ||
|
|
63f541d61f | ||
|
|
7829a49e9d | ||
|
|
ec0445fbcb | ||
|
|
ab1fc3f00f | ||
|
|
a0a0e522ef | ||
|
|
2e496a3f21 | ||
|
|
526e4adf0c | ||
|
|
1e04f132b8 | ||
|
|
640f712037 | ||
|
|
1c391448c1 | ||
|
|
25c3c5ba4d | ||
|
|
6f54e0f9df | ||
|
|
ba69b9f21d | ||
|
|
171aebb6f8 | ||
|
|
adc47beb72 | ||
|
|
cc64bb4644 | ||
|
|
6ea4a67672 | ||
|
|
8aa6bb7235 | ||
|
|
e58b4f6614 | ||
|
|
e40d93e7be | ||
|
|
28846c561c | ||
|
|
af7d2b358d | ||
|
|
101c457f7e | ||
|
|
dafbf5bad4 | ||
|
|
63fd90e017 | ||
|
|
648c10f2a7 | ||
|
|
8f1518b0b3 |
+14
-5
@@ -1,13 +1,23 @@
|
||||
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
|
||||
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.egg-info/
|
||||
backend/.venv/
|
||||
backend/.pytest_cache/
|
||||
backend/*.db
|
||||
backend/*.db-wal
|
||||
backend/*.db-shm
|
||||
backend/openapi.json
|
||||
|
||||
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
|
||||
# локальные копии бэкапов — ни в образ, ни в контекст сборки
|
||||
backend/data/
|
||||
data/
|
||||
backups/
|
||||
**/*.db
|
||||
**/*.db-wal
|
||||
**/*.db-shm
|
||||
|
||||
# Node / сборка фронта
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
@@ -23,8 +33,7 @@ backend/tests/
|
||||
backend/pyproject.toml
|
||||
|
||||
# Прочее
|
||||
.env
|
||||
data/
|
||||
**/.env
|
||||
**/.DS_Store
|
||||
*.md
|
||||
.claude/
|
||||
|
||||
+30
-19
@@ -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
|
||||
|
||||
@@ -3,16 +3,3 @@
|
||||
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
||||
*.sh text eol=lf
|
||||
backend/entrypoint.sh text eol=lf
|
||||
|
||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ─────
|
||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
||||
# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет —
|
||||
# там чистоту образа обеспечивает .dockerignore.
|
||||
backend/tests/ export-ignore
|
||||
backend/app/auth/dev_stub.py export-ignore
|
||||
backend/app/routers/dev_auth.py export-ignore
|
||||
backend/pyproject.toml export-ignore
|
||||
README.md export-ignore
|
||||
.gitignore export-ignore
|
||||
.gitattributes export-ignore
|
||||
.dockerignore export-ignore
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 <dir> main` (через
|
||||
`git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа).
|
||||
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
||||
|
||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||
|
||||
@@ -200,21 +221,32 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
|
||||
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
|
||||
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
|
||||
У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост
|
||||
они не публикуют (доступны только через домен):
|
||||
У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он
|
||||
не публикует (доступен только через домен):
|
||||
|
||||
| | домен | как выставляется | слот VPS |
|
||||
|---|---|---|---|
|
||||
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
|
||||
| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 |
|
||||
| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||
|
||||
- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают
|
||||
одновременно. Dev и test делят слот 9001 → по очереди.
|
||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен.
|
||||
- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный
|
||||
прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
|
||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
|
||||
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
|
||||
время проверки (задача #69).
|
||||
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
|
||||
- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi —
|
||||
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
|
||||
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
|
||||
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
|
||||
`pi/` (app + туннель в Docker), образ туннеля — `deploy/tunnel/`. Ключ — `deploy/tunnel/id_tunnel`.
|
||||
`pi/` (app + туннель + бэкапы в Docker), образ туннеля — `deploy/tunnel/`.
|
||||
|
||||
## Бэкапы
|
||||
|
||||
Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок
|
||||
БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки
|
||||
скачиваются со сверкой sha256 (`scripts/fs-backup.ps1 pull`).
|
||||
Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||
|
||||
## Дополнения и фракции
|
||||
|
||||
@@ -225,4 +257,6 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
| Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
|
||||
|
||||
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
|
||||
этих дополнений (база — всегда).
|
||||
этих дополнений (база — всегда). Справочник сидится из `backend/app/seed/reference_data.py`.
|
||||
Админ может переименовать фракцию в панели, но сейчас переименование откатывается при
|
||||
каждом перезапуске приложения (задача #72).
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
|
||||
|
||||
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
|
||||
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
|
||||
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
|
||||
last_standing.
|
||||
|
||||
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
|
||||
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
|
||||
|
||||
Revision ID: 0014_rating_inputs
|
||||
Revises: 0013_user_token_version
|
||||
Create Date: 2026-09-14
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_rating_inputs"
|
||||
down_revision: Union[str, None] = "0013_user_token_version"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
CK_WIN_REASON = "ck_match_win_reason"
|
||||
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
|
||||
NEW_REASONS = (
|
||||
"win_reason IS NULL OR win_reason IN "
|
||||
"('objectives','worlds','plastic','resources','last_standing')"
|
||||
)
|
||||
|
||||
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
||||
# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта
|
||||
# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была.
|
||||
BACKFILL_LAST_STANDING = """
|
||||
UPDATE matches SET win_reason = 'last_standing'
|
||||
WHERE status = 'finished'
|
||||
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
||||
AND (SELECT COUNT(*) FROM match_participants mp
|
||||
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
|
||||
AND (SELECT COUNT(*) FROM match_participants mp WHERE mp.match_id = matches.id) >= 2
|
||||
"""
|
||||
|
||||
|
||||
def _columns(insp, table: str) -> set[str]:
|
||||
return {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def _win_reason_check(insp) -> str | None:
|
||||
for ck in insp.get_check_constraints("matches"):
|
||||
if ck.get("name") == CK_WIN_REASON:
|
||||
return ck["sqltext"]
|
||||
return None
|
||||
|
||||
|
||||
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
|
||||
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
|
||||
|
||||
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
|
||||
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
|
||||
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
|
||||
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
|
||||
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
|
||||
raise RuntimeError(
|
||||
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
|
||||
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
|
||||
)
|
||||
with op.batch_alter_table("matches", recreate="always") as b:
|
||||
if drop_existing:
|
||||
b.drop_constraint(CK_WIN_REASON, type_="check")
|
||||
b.create_check_constraint(CK_WIN_REASON, sqltext)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
|
||||
if "nine_rounds_rule" not in _columns(insp, "groups"):
|
||||
with op.batch_alter_table("groups") as b:
|
||||
b.add_column(
|
||||
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
match_cols = _columns(insp, "matches")
|
||||
with op.batch_alter_table("matches") as b:
|
||||
if "end_round" not in match_cols:
|
||||
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
|
||||
if "nine_rounds_rule" not in match_cols:
|
||||
b.add_column(
|
||||
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
participant_cols = _columns(insp, "match_participants")
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
if "objectives" not in participant_cols:
|
||||
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
|
||||
if "worlds" not in participant_cols:
|
||||
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
|
||||
|
||||
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
|
||||
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
|
||||
check = _win_reason_check(inspect(bind))
|
||||
if check is not None and "last_standing" not in check:
|
||||
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
|
||||
|
||||
op.execute(BACKFILL_LAST_STANDING)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
|
||||
# записывали «по целям»).
|
||||
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
|
||||
check = _win_reason_check(inspect(bind))
|
||||
if check is not None and "last_standing" in check:
|
||||
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
|
||||
|
||||
insp = inspect(bind)
|
||||
participant_cols = _columns(insp, "match_participants")
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
for name in ("worlds", "objectives"):
|
||||
if name in participant_cols:
|
||||
b.drop_column(name)
|
||||
match_cols = _columns(insp, "matches")
|
||||
with op.batch_alter_table("matches") as b:
|
||||
for name in ("nine_rounds_rule", "end_round"):
|
||||
if name in match_cols:
|
||||
b.drop_column(name)
|
||||
if "nine_rounds_rule" in _columns(insp, "groups"):
|
||||
with op.batch_alter_table("groups") as b:
|
||||
b.drop_column("nine_rounds_rule")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Слияние веток миграций: рейтинг (0014) и объявления (0015).
|
||||
|
||||
Объявления (#84) вышли в main раньше рейтинга, поэтому 0015 отходит от 0013, а не от 0014,
|
||||
и у графа миграций две ветки. Эта миграция сводит их в одну голову и сама ничего не меняет:
|
||||
прод, стоящий на 0015 (релиз без рейтинга), при переходе на эту версию получит 0014 и её;
|
||||
база на 0014 (dev) — 0015 и её.
|
||||
|
||||
Revision ID: 0016_merge_rating_announcements
|
||||
Revises: 0014_rating_inputs, 0015_announcements
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0016_merge_rating_announcements"
|
||||
down_revision: Union[str, Sequence[str], None] = ("0014_rating_inputs", "0015_announcements")
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
||||
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
||||
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
||||
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
||||
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
|
||||
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
|
||||
включая IP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||
|
||||
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
||||
и dev-входом.
|
||||
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
|
||||
Telegram, /auth/login, /auth/register и dev-вход.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
||||
Доступен во всех окружениях — наряду со входом по логину и паролю.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+60
-28
@@ -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
|
||||
|
||||
@@ -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 / многих адресов
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
||||
|
||||
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
|
||||
где БД поднимается из чистого состояния.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.db.session import engine
|
||||
from app.seed.reference_data import seed_reference_data
|
||||
from sqlmodel import Session
|
||||
|
||||
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
|
||||
import app.models # noqa: F401
|
||||
|
||||
|
||||
def create_db_and_seed() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
seed_reference_data(session)
|
||||
+14
-4
@@ -132,8 +132,18 @@ async def _lifespan(_app: FastAPI):
|
||||
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
|
||||
if settings.is_development and settings.is_published:
|
||||
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
|
||||
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
|
||||
logging.getLogger("fs").warning(
|
||||
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
|
||||
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
|
||||
"и Swagger. Не держите в dev-базе копию прод-данных.",
|
||||
settings.local_public,
|
||||
)
|
||||
|
||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||
try:
|
||||
from app.bootstrap import bootstrap
|
||||
@@ -152,7 +162,7 @@ async def _lifespan(_app: FastAPI):
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
|
||||
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для
|
||||
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
||||
# незачем облегчать разведку поверхности API анонимам (#61).
|
||||
docs_enabled = not settings.is_production
|
||||
@@ -166,7 +176,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
|
||||
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||
if settings.is_development and settings.cors_origins_list:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -206,7 +216,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(r, prefix="/api")
|
||||
|
||||
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
||||
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
|
||||
# и только если код физически есть (в прод-образе dev_*-файлы исключены
|
||||
# .dockerignore, импорт просто не выполнится).
|
||||
if settings.is_development:
|
||||
for mod_name in ("dev_auth", "dev_admin"):
|
||||
|
||||
+20
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||||
|
||||
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
|
||||
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
|
||||
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||||
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||||
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
|
||||
возможность удаления не попадает в прод — там аккаунт можно только
|
||||
отключить (PATCH is_active).
|
||||
|
||||
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (файл исключён из прод-образа).
|
||||
|
||||
|
||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.events import hub
|
||||
from app.models import GroupMember, Match, MatchParticipant, User
|
||||
from app.models import GroupMember, Match, User
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
@@ -17,30 +17,28 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
)
|
||||
|
||||
|
||||
def match_participant_ids(session: Session, match_id: int) -> list[int]:
|
||||
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
|
||||
(история игр, публичный профиль, личная статистика) реально протухли."""
|
||||
return list(
|
||||
session.exec(
|
||||
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
|
||||
def _ratings_changed(session: Session, notified: list[int]) -> None:
|
||||
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
|
||||
главную, историю и профили всех, кто играл после неё, и страницы других групп.
|
||||
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
|
||||
skip = set(notified)
|
||||
ids = [
|
||||
uid
|
||||
for uid in session.exec(
|
||||
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
||||
).all()
|
||||
)
|
||||
if uid not in skip
|
||||
]
|
||||
hub.publish(ids, {"type": "ratings"})
|
||||
|
||||
|
||||
def match_changed(session: Session, match: Match) -> None:
|
||||
"""Партия изменилась — уведомить всех участников её группы.
|
||||
|
||||
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
|
||||
история и профили протухают только у игравших, поэтому их id едут в событии."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, match.group_id),
|
||||
{
|
||||
"type": "match",
|
||||
"match_id": match.id,
|
||||
"group_id": match.group_id,
|
||||
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
|
||||
},
|
||||
)
|
||||
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
|
||||
и остальных игроков (_ratings_changed)."""
|
||||
members = _group_member_ids(session, match.group_id)
|
||||
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
|
||||
if match.status == "finished":
|
||||
_ratings_changed(session, members)
|
||||
|
||||
|
||||
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||
@@ -53,22 +51,13 @@ def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
||||
|
||||
|
||||
def match_removed(
|
||||
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
|
||||
) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки).
|
||||
|
||||
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
|
||||
ушли каскадом, и собрать их из базы невозможно."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, group_id),
|
||||
{
|
||||
"type": "match",
|
||||
"match_id": match_id,
|
||||
"group_id": group_id,
|
||||
"participant_ids": participant_ids or [],
|
||||
},
|
||||
)
|
||||
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
|
||||
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
|
||||
members = _group_member_ids(session, group_id)
|
||||
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
|
||||
if finished:
|
||||
_ratings_changed(session, members)
|
||||
|
||||
|
||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||
|
||||
+198
-36
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from app.models import Faction
|
||||
from app.seed.reference_data import FACTIONS, seed_reference_data
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||
|
||||
|
||||
@@ -205,6 +209,37 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
|
||||
assert ap["faction_name"] == "Орки WAAAGH"
|
||||
|
||||
|
||||
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
|
||||
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
|
||||
имя, заданное админом (#72)."""
|
||||
login(client, "Кто-то")
|
||||
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
|
||||
_admin_login(client, make_admin)
|
||||
r = client.patch(
|
||||
f"/api/admin/factions/{orks['id']}",
|
||||
json={"name_ru": "Орки WAAAGH"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
with Session(engine) as s:
|
||||
seed_reference_data(s) # то же, что делает рестарт
|
||||
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
|
||||
|
||||
|
||||
def test_seeding_is_complete_and_idempotent():
|
||||
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
|
||||
engine = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as s:
|
||||
seed_reference_data(s)
|
||||
seed_reference_data(s)
|
||||
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
|
||||
assert names == {code: name for code, name, _exp, _order in FACTIONS}
|
||||
|
||||
|
||||
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
||||
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Ротация пароля администратора из .env (#73): `python -m app.bootstrap --reset-admin-password`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app import bootstrap
|
||||
from app.core.security import verify_password
|
||||
from app.models import User
|
||||
from tests.conftest import csrf_headers
|
||||
|
||||
OLD, NEW = "secret123", "brand-new-admin-pw"
|
||||
|
||||
|
||||
def _admin(engine) -> User:
|
||||
with Session(engine) as s:
|
||||
return s.exec(select(User).where(User.role == "admin")).one()
|
||||
|
||||
|
||||
def _login(client: TestClient, password: str):
|
||||
return client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": password},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
|
||||
|
||||
def test_reset_applies_env_password_and_revokes_sessions(
|
||||
client: TestClient, engine, make_admin, monkeypatch
|
||||
):
|
||||
make_admin("admin", OLD)
|
||||
assert _login(client, OLD).status_code == 200
|
||||
assert client.get("/api/admin/me").status_code == 200
|
||||
version = _admin(engine).token_version
|
||||
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s:
|
||||
bootstrap.reset_admin_password(s)
|
||||
|
||||
admin = _admin(engine)
|
||||
assert verify_password(NEW, admin.password_hash)
|
||||
assert admin.token_version == version + 1
|
||||
# Прежняя админская cookie больше не действует (в т.ч. у того, кто украл пароль).
|
||||
assert client.get("/api/admin/me").status_code == 401
|
||||
assert _login(client, OLD).status_code == 401
|
||||
assert _login(client, NEW).status_code == 200
|
||||
|
||||
|
||||
def test_regular_bootstrap_keeps_admin_password_outside_dev(engine, make_admin, monkeypatch):
|
||||
"""Обычный старт в prod по-прежнему не берёт пароль из .env — только ротация."""
|
||||
make_admin("admin", OLD)
|
||||
monkeypatch.setattr(bootstrap.settings, "app_env", "production")
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s:
|
||||
bootstrap._ensure_admin(s)
|
||||
assert verify_password(OLD, _admin(engine).password_hash)
|
||||
|
||||
|
||||
def test_reset_refuses_without_admin_or_password(engine, make_admin, monkeypatch):
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s, pytest.raises(RuntimeError, match="Администратора ещё нет"):
|
||||
bootstrap.reset_admin_password(s)
|
||||
|
||||
make_admin("admin", OLD)
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", " ")
|
||||
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_PASSWORD пуст"):
|
||||
bootstrap.reset_admin_password(s)
|
||||
assert verify_password(OLD, _admin(engine).password_hash)
|
||||
|
||||
|
||||
def test_reset_refuses_when_bootstrap_disabled(engine, make_admin, monkeypatch):
|
||||
make_admin("admin", OLD)
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_bootstrap_enabled", False)
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_BOOTSTRAP_ENABLED"):
|
||||
bootstrap.reset_admin_password(s)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6)."""
|
||||
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)]
|
||||
@@ -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, "А", "Б", "В")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+26
-26
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+112
-47
@@ -16,7 +16,7 @@
|
||||
4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК
|
||||
5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi
|
||||
6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК
|
||||
7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК
|
||||
7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК
|
||||
8. [Восстановление прода](#8-восстановление-прода) — Pi
|
||||
9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi
|
||||
10. [Повседневные действия](#10-повседневные-действия)
|
||||
@@ -55,8 +55,8 @@
|
||||
4. удаляет старые снимки по правилам хранения;
|
||||
5. проверяет, что репозитории целы.
|
||||
|
||||
Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных и убеждается,
|
||||
что последний снимок действительно восстанавливается.
|
||||
Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных (10 %) и
|
||||
убеждается, что БД из последнего снимка извлекается и проходит проверку целостности.
|
||||
|
||||
**Словарь**
|
||||
|
||||
@@ -562,43 +562,42 @@
|
||||
|
||||
---
|
||||
|
||||
## Шаг 7. Учебное восстановление на тест-клоне
|
||||
## Шаг 7. Проверка скачанного архива
|
||||
|
||||
**Где:** ПК с Docker Desktop. **Зачем:** убедиться, что бэкап действительно
|
||||
восстанавливается, **до** того как это понадобится по-настоящему. Прод не затрагивается.
|
||||
**Где:** ПК. **Зачем:** убедиться, что БД в скачанном снимке целая и в ней те данные, что
|
||||
ожидаются, **до** того как это понадобится по-настоящему. Прод не затрагивается.
|
||||
|
||||
> Данные тест-клона на ПК будут заменены данными из архива. Прежние данные тест-клона
|
||||
> сохраняются в его собственный снимок `pre-restore`.
|
||||
> Отдельного тестового контейнера для учебного восстановления больше нет. Сам механизм
|
||||
> `restore`/`import` отрабатывает только на Pi ([раздел 8](#8-восстановление-прода)); здесь
|
||||
> проверяется содержимое архива.
|
||||
|
||||
1. Восстановите скачанный архив в тест-клон (подставьте имя своего файла):
|
||||
1. Распакуйте последний скачанный архив во временную папку:
|
||||
|
||||
```powershell
|
||||
.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260914_0400_3f2a9c1d.tar
|
||||
$f = (Get-ChildItem backups\fs_*.tar | Sort-Object LastWriteTime | Select-Object -Last 1).FullName
|
||||
$d = Join-Path $env:TEMP "fs-check"; New-Item -ItemType Directory -Force $d | Out-Null
|
||||
tar -xf $f -C $d
|
||||
```
|
||||
|
||||
В первый раз Docker соберёт образы тест-клона — это несколько минут.
|
||||
2. Откройте `%TEMP%\fs-check\forbidden_stars.db` в [DB Browser for SQLite](https://sqlitebrowser.org)
|
||||
(вкладка «Выполнить SQL») и выполните:
|
||||
|
||||
2. Проверьте, что приложение тест-клона поднялось:
|
||||
```sql
|
||||
PRAGMA integrity_check;
|
||||
SELECT (SELECT count(*) FROM users WHERE role = 'player') AS players,
|
||||
(SELECT count(*) FROM matches) AS matches;
|
||||
```
|
||||
|
||||
3. Закройте DB Browser и удалите временную папку — данные в ней не зашифрованы:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.test.yml ps
|
||||
docker compose -f docker-compose.test.yml logs --tail 20 app
|
||||
Remove-Item -Recurse -Force (Join-Path $env:TEMP "fs-check")
|
||||
```
|
||||
|
||||
3. Посмотрите на сайт: в `.env` на ПК временно поставьте `APP_ENV=test` и запустите
|
||||
`.\run.ps1`. Тест-клон откроется на `https://forbidden-stars.ru`: проверьте топ,
|
||||
профили, историю партий. Потом верните `APP_ENV=development`.
|
||||
|
||||
**Что должно получиться:**
|
||||
- в выводе `restore-test`:
|
||||
- `Развёрнутые данные в порядке: игроков N, партий M.` — те же числа, что в `list` на Pi;
|
||||
- `Данные восстановлены.`;
|
||||
- `Done. The test clone now runs on the restored data.`;
|
||||
- `docker compose ... ps` показывает `app` в состоянии `Up … (healthy)`;
|
||||
- на сайте тест-клона — данные прода на момент снимка.
|
||||
|
||||
> Этим же способом можно восстановить в тест-клон старые архивы `fs_*.tar.gz` прежнего
|
||||
> `scripts/backup.sh`: `.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260710_140914.tar.gz`.
|
||||
- `PRAGMA integrity_check` → `ok`;
|
||||
- `players` и `matches` совпадают со столбцами `Игроков` / `Партий` этого снимка в `list` на Pi;
|
||||
- в папке рядом с БД есть `uploads\…` (фото партий) и, если заводились, `achievements\…`.
|
||||
|
||||
---
|
||||
|
||||
@@ -646,13 +645,14 @@
|
||||
| `Игроков` / `Партий` | сколько игроков и партий было в БД в этот момент — по ним легко найти «до поломки» |
|
||||
| `Размер` | полный объём данных снимка |
|
||||
| `Прирост` | сколько места снимок реально добавил в репозиторий |
|
||||
| `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, прочие — имя, данное вручную |
|
||||
| `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, `keep` + имя — именованный снимок (`run --tag имя`) |
|
||||
|
||||
Если локальный репозиторий повреждён или пуст, смотрите копию на VPS:
|
||||
`docker compose exec backup fs-backup list vps`.
|
||||
|
||||
2. **По желанию, но рекомендуется:** сначала отрепетируйте на ПК:
|
||||
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)).
|
||||
2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК:
|
||||
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем [шаг 7](#шаг-7-проверка-скачанного-архива).
|
||||
Заодно у вас останется копия этого снимка вне Pi.
|
||||
|
||||
3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»:
|
||||
|
||||
@@ -737,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 <ID> --repo vps --yes
|
||||
docker compose start app
|
||||
```
|
||||
|
||||
Вместо `latest` можно указать ID из `list vps`.
|
||||
Если самый свежий снимок в `list vps` — с данными, вместо ID можно написать `latest`.
|
||||
|
||||
6. Проверьте сайт. Затем сделайте снимок вручную, чтобы локальная копия появилась сразу:
|
||||
|
||||
@@ -792,7 +813,7 @@
|
||||
- **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`).
|
||||
- **Раз в месяц:**
|
||||
- скачать снимок на ПК (`pull`);
|
||||
- раз в пару месяцев сделать учебное восстановление (`restore-test`);
|
||||
- проверить скачанный архив ([шаг 7](#шаг-7-проверка-скачанного-архива));
|
||||
- удалить с ПК старые архивы — они не зашифрованы.
|
||||
- **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что
|
||||
бэкапы перестали проходить (причину покажет `fs-backup status`).
|
||||
@@ -817,6 +838,42 @@ ID одного и того же снимка в `local` и `vps` разные
|
||||
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
||||
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
||||
|
||||
### Отчёты в Telegram
|
||||
|
||||
Бот приложения (тот же, что для входа через Telegram) может присылать вам отчёты о бэкапах
|
||||
и отвечать на команды. Пока `BACKUP_TELEGRAM_CHAT_ID` пуст, отчёты выключены.
|
||||
|
||||
**Что приходит само:**
|
||||
- после каждого бэкапа — «✅ Бэкап»: сколько игроков и партий в БД, по каждому репозиторию
|
||||
снимок, число снимков и размер;
|
||||
- если бэкап не удался, в том числе не начавшись (нет пароля, занята другая операция, БД
|
||||
не прошла проверку) — «❌ Бэкап не удался» с причиной и упавшими репозиториями;
|
||||
- после еженедельной проверки — «🔍 Проверка данных: OK» или «❌».
|
||||
|
||||
**Команды боту** (отвечает только чатам из `BACKUP_TELEGRAM_CHAT_ID`):
|
||||
- `/backups` — хранящиеся снимки: по репозиторию число и размер, 10 последних с временем,
|
||||
игроками и партиями, 📌 у именованных;
|
||||
- `/status` — то же, что `fs-backup status`;
|
||||
- `/help` — список команд.
|
||||
|
||||
**Настройка:**
|
||||
1. В Telegram найдите бота приложения и напишите ему `/start`.
|
||||
2. Узнайте id своего чата:
|
||||
```bash
|
||||
docker compose exec backup fs-backup telegram chats
|
||||
# 123456789 @you /start
|
||||
```
|
||||
Когда бот уже работает (id вписан), он сам забирает сообщения. Тогда id нового чата
|
||||
ищите в журнале: `docker compose logs backup | grep "чужого чата"`.
|
||||
3. Впишите id в `.env` — `BACKUP_TELEGRAM_CHAT_ID=123456789`, несколько через запятую —
|
||||
и пересоздайте контейнер: `docker compose up -d backup`.
|
||||
4. Проверьте связь: `docker compose exec backup fs-backup telegram test` — в чат придёт
|
||||
пробное сообщение.
|
||||
|
||||
Токен бота по умолчанию берётся из `TELEGRAM_BOT_TOKEN` приложения. Чтобы слать отчёты от
|
||||
другого бота, задайте `BACKUP_TELEGRAM_BOT_TOKEN`. Если Telegram недоступен, бэкапы работают
|
||||
как обычно — в журнале будет только строка «Telegram: … не отправлено».
|
||||
|
||||
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
||||
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
||||
> не трогает.
|
||||
@@ -838,6 +895,8 @@ docker compose logs --tail 100 backup
|
||||
| Симптом | Причина | Что сделать |
|
||||
|---|---|---|
|
||||
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
||||
| отчёты в Telegram не приходят, `telegram test` пишет «отправить не удалось» | неверный chat id или токен, боту не писали `/start` | раздел 10, «Отчёты в Telegram»: написать боту, взять id из журнала, `docker compose up -d backup` |
|
||||
| бот не отвечает на `/backups`, в журнале «сообщение из чужого чата» | ваш id не в `BACKUP_TELEGRAM_CHAT_ID` | вписать id из этой строки журнала, `docker compose up -d backup` |
|
||||
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
||||
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
||||
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
||||
@@ -852,7 +911,7 @@ docker compose logs --tail 100 backup
|
||||
| `снимок '…' не найден в репозитории` | опечатка в ID или снимок в другом репозитории | `fs-backup list` и `fs-backup list vps` |
|
||||
| `service "backup" is not running` | контейнер не запущен | `docker compose up -d backup` |
|
||||
| `permission denied while trying to connect to the Docker daemon socket` | пользователь Pi не в группе `docker` | `sudo usermod -aG docker $USER`, перезайти по SSH |
|
||||
| `docker compose ps` → `backup` `unhealthy` | последний успешный бэкап старше `BACKUP_MAX_AGE_HOURS` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице |
|
||||
| `docker compose ps` → `backup` `unhealthy` | последний успешный бэкап старше `BACKUP_MAX_AGE_HOURS`, успешных бэкапов ещё не было или не задан `BACKUP_PASSWORD` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице |
|
||||
|
||||
**Про `recover`.** Команда сама определяет, на каком этапе оборвалось восстановление:
|
||||
- если замена успела пройти во всех томах — дочищает промежуточные папки, восстановленные
|
||||
@@ -885,7 +944,7 @@ docker volume rm <имя тома>
|
||||
| `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` |
|
||||
| `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) |
|
||||
| `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `pull` |
|
||||
| `restore-test`: `Import failed … The test clone data was not changed` | архив повреждён или неполный | скачать заново (`pull`); текст ошибки выше в выводе |
|
||||
| шаг 7: `integrity_check` не `ok` или счётчики не совпадают с `list` | архив повреждён или скачан не тот снимок | удалить файл из `backups\` и скачать заново (`pull -Snapshot <ID>`); если повторяется — `fs-backup verify` на Pi |
|
||||
|
||||
---
|
||||
|
||||
@@ -906,9 +965,15 @@ docker volume rm <имя тома>
|
||||
| `recover` | разбор прерванного восстановления |
|
||||
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
||||
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
||||
| `telegram chats` | id чатов, писавших боту, — для `BACKUP_TELEGRAM_CHAT_ID` |
|
||||
| `telegram test` | пробное сообщение в чаты `BACKUP_TELEGRAM_CHAT_ID` |
|
||||
| `telegram bot` | бот-слушатель команд `/backups`, `/status` (контейнер запускает его сам) |
|
||||
| `help` | краткая справка |
|
||||
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
||||
|
||||
Без `--yes` команды `restore` и `import` только показывают, что собираются сделать.
|
||||
Флаг `--no-pre-restore` у `restore`/`import` пропускает страховочный снимок — используйте,
|
||||
только если текущие данные точно не нужны.
|
||||
|
||||
### Скрипт ПК
|
||||
|
||||
@@ -918,11 +983,9 @@ docker volume rm <имя тома>
|
||||
|---|---|
|
||||
| `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК |
|
||||
| `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 |
|
||||
| `restore-test -File <архив>` | учебное восстановление в локальный тест-клон |
|
||||
| `-Target test` | выполнить `status`/`list`/`now`/`verify`/`pull` на локальном тест-клоне |
|
||||
|
||||
В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`,
|
||||
`pull <ID> --repo vps`, `restore-test <архив>`, `--test` первым аргументом.
|
||||
`pull <ID> --repo vps`.
|
||||
|
||||
### Переменные `.env`
|
||||
|
||||
@@ -940,6 +1003,8 @@ docker volume rm <имя тома>
|
||||
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
||||
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
||||
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
||||
| `BACKUP_TELEGRAM_CHAT_ID` | Pi | — | id чатов для отчётов и команд, через запятую; пусто = без Telegram |
|
||||
| `BACKUP_TELEGRAM_BOT_TOKEN` | Pi | `TELEGRAM_BOT_TOKEN` | токен бота, если отчёты должен слать другой бот |
|
||||
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
||||
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
||||
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
||||
@@ -970,7 +1035,7 @@ docker volume rm <имя тома>
|
||||
- [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5)
|
||||
- [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5)
|
||||
- [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6)
|
||||
- [ ] Учебное восстановление на тест-клоне прошло, данные на месте (шаг 7)
|
||||
- [ ] Скачанный архив проверен: БД целая, числа совпадают с `list` (шаг 7)
|
||||
|
||||
**Через сутки**
|
||||
- [ ] В `list` появился снимок с меткой `scheduled` в 04:00
|
||||
|
||||
@@ -5,6 +5,14 @@ set -eu
|
||||
|
||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||||
|
||||
# Telegram-бот (#83): отвечает владельцу на /backups и /status. Стартует раньше проверки
|
||||
# пароля — при выключенных бэкапах скажет, что они выключены. Упал — перезапуск через минуту.
|
||||
if [ -n "${BACKUP_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${BACKUP_TELEGRAM_CHAT_ID:-}" ]; then
|
||||
( while :; do fs-backup telegram bot || true; sleep 60; done ) &
|
||||
else
|
||||
log "Telegram не настроен (BACKUP_TELEGRAM_CHAT_ID/токен бота) — отчёты в Telegram выключены."
|
||||
fi
|
||||
|
||||
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
||||
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
||||
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
||||
@@ -29,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
|
||||
fi
|
||||
|
||||
if ! fs-backup has-snapshots; then
|
||||
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу."
|
||||
# Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
|
||||
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
|
||||
# равно: guard_empty не сохранит БД без таблиц.
|
||||
_app="${BACKUP_APP_HOST:-app}"
|
||||
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
|
||||
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
|
||||
_waited=0
|
||||
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
|
||||
if [ "$_waited" -ge "$_wait" ]; then
|
||||
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
_waited=$((_waited + 10))
|
||||
done
|
||||
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
||||
fi
|
||||
|
||||
|
||||
+243
-4
@@ -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' -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 <chat_id> <html> — одно сообщение в один чат
|
||||
tg_api sendMessage -o /dev/null \
|
||||
--data-urlencode "chat_id=$1" \
|
||||
--data-urlencode "text=$2" \
|
||||
--data-urlencode "parse_mode=HTML" \
|
||||
--data-urlencode "disable_web_page_preview=true"
|
||||
}
|
||||
|
||||
tg_send() { # tg_send <html> — во все чаты владельца
|
||||
tg_enabled || return 0
|
||||
for _chat in $(tg_chat_ids); do
|
||||
tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено."
|
||||
done
|
||||
}
|
||||
|
||||
repo_summary() { # repo_summary <repo> → «снимков 30 · 45.2 MB»
|
||||
if _st="$(r "$1" stats --mode raw-data --json 2>/dev/null)"; then
|
||||
printf '%s' "$_st" | jq -r '"\(.snapshots_count)\t\(.total_size)"' | {
|
||||
IFS="$(printf '\t')" read -r _cnt _size
|
||||
echo "снимков $_cnt · $(human "$_size")"
|
||||
}
|
||||
else
|
||||
echo "размер недоступен"
|
||||
fi
|
||||
}
|
||||
|
||||
# Итог run/verify одним сообщением. Списки — «репо:поле:поле» через пробел; их собирают
|
||||
# cmd_run/cmd_verify, а вызывает обработчик EXIT — так отчёт уходит и при раннем отказе.
|
||||
notify_run() { # notify_run <код выхода>
|
||||
tg_enabled || return 0
|
||||
_when="$(date '+%d.%m %H:%M')"
|
||||
_db="БД: игроков $_players, партий $_matches"
|
||||
if [ "$1" -eq 0 ]; then
|
||||
_text="✅ <b>Бэкап</b> $_when
|
||||
$_db"
|
||||
else
|
||||
_text="❌ <b>Бэкап не удался</b> $_when
|
||||
$(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)"
|
||||
[ "$_players" = "?" ] || _text="$_text
|
||||
$_db"
|
||||
fi
|
||||
for _item in $_ok_repos; do
|
||||
_text="$_text
|
||||
• ${_item%%:*}: снимок <code>${_item#*:}</code> · $(repo_summary "${_item%%:*}")"
|
||||
done
|
||||
for _repo in $_failed; do
|
||||
_text="$_text
|
||||
• $_repo: ошибка"
|
||||
done
|
||||
[ "$1" -eq 0 ] || _text="$_text
|
||||
Подробности: <code>docker compose logs backup</code>"
|
||||
tg_send "$_text"
|
||||
}
|
||||
|
||||
notify_verify() { # notify_verify <код выхода>
|
||||
tg_enabled || return 0
|
||||
_when="$(date '+%d.%m %H:%M')"
|
||||
if [ "$1" -eq 0 ]; then
|
||||
_text="🔍 <b>Проверка данных: OK</b> $_when"
|
||||
else
|
||||
_text="❌ <b>Проверка данных не прошла</b> $_when
|
||||
$(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)"
|
||||
fi
|
||||
for _item in $_verified; do
|
||||
_rest="${_item#*:}"
|
||||
_text="$_text
|
||||
• ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}"
|
||||
done
|
||||
for _repo in $_failed; do
|
||||
_text="$_text
|
||||
• $_repo: ошибка"
|
||||
done
|
||||
[ "$1" -eq 0 ] || _text="$_text
|
||||
Подробности: <code>docker compose logs backup</code>"
|
||||
tg_send "$_text"
|
||||
}
|
||||
|
||||
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
||||
db_ok() {
|
||||
[ -s "$1" ] || return 1
|
||||
@@ -178,6 +275,17 @@ forget_repo() {
|
||||
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
||||
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
||||
guard_empty() { # guard_empty <игроков> <партий>
|
||||
# «?» — счётчики не прочитались: в БД нет таблиц (миграции ещё не прошли). Такой снимок
|
||||
# стал бы latest и сломал бы restore latest (#74) — отказ, как и для пустой БД.
|
||||
case "$1$2" in
|
||||
*'?'*)
|
||||
for _r in $(repos); do mark "run-$_r" err "в БД нет таблиц users/matches — бэкап не сделан"; done
|
||||
log "Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches."
|
||||
log "Похоже, приложение ещё не применило миграции. Бэкап НЕ сделан, чтобы снимок без данных"
|
||||
log "не стал последним. Дождитесь запуска приложения; если так и задумано:"
|
||||
die "fs-backup run --allow-empty"
|
||||
;;
|
||||
esac
|
||||
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
|
||||
for _repo in $(repos); do
|
||||
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
|
||||
@@ -236,11 +344,18 @@ cmd_run() {
|
||||
esac
|
||||
shift
|
||||
done
|
||||
_players="?"
|
||||
_matches="?"
|
||||
_ok_repos=""
|
||||
_failed=""
|
||||
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
|
||||
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
|
||||
trap '_rc=$?; notify_run "$_rc"' EXIT
|
||||
require_password
|
||||
setup_ssh
|
||||
take_lock
|
||||
warn_leftovers
|
||||
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
|
||||
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
|
||||
|
||||
log "Снимок данных: консистентная копия БД…"
|
||||
stage_db
|
||||
@@ -252,7 +367,6 @@ cmd_run() {
|
||||
log "БД в порядке: игроков $_players, партий $_matches."
|
||||
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
||||
|
||||
_failed=""
|
||||
for _repo in $(repos); do
|
||||
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
||||
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
||||
@@ -260,6 +374,7 @@ cmd_run() {
|
||||
&& r "$_repo" check >&2; then
|
||||
_sid="$(latest_short_id "$_repo")"
|
||||
mark "run-$_repo" ok "снимок $_sid"
|
||||
_ok_repos="$_ok_repos $_repo:$_sid"
|
||||
log "OK: репозиторий $_repo, снимок $_sid."
|
||||
else
|
||||
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
||||
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
|
||||
|
||||
# ─── verify ───────────────────────────────────────────────────────────────────
|
||||
cmd_verify() {
|
||||
_verified=""
|
||||
_failed=""
|
||||
trap '_rc=$?; notify_verify "$_rc"' EXIT
|
||||
require_password
|
||||
setup_ssh
|
||||
take_lock
|
||||
_failed=""
|
||||
for _repo in $(repos); do
|
||||
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
||||
_tmp="$RUNTIME_DIR/verify.db"
|
||||
@@ -378,6 +495,7 @@ cmd_verify() {
|
||||
&& db_ok "$_tmp"; then
|
||||
_counts="$(db_counts "$_tmp")"
|
||||
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
||||
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
|
||||
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
||||
else
|
||||
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
||||
@@ -779,6 +897,121 @@ cmd_init() {
|
||||
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
||||
}
|
||||
|
||||
# ─── telegram ─────────────────────────────────────────────────────────────────
|
||||
tg_help_text() {
|
||||
cat <<'EOF'
|
||||
<b>Бэкапы Forbidden Stars</b>
|
||||
/backups — хранящиеся снимки по репозиториям
|
||||
/status — последние бэкапы и проверки, размеры
|
||||
Отчёт о каждом бэкапе и проверке приходит сюда сам.
|
||||
EOF
|
||||
}
|
||||
|
||||
tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних
|
||||
[ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; }
|
||||
setup_ssh
|
||||
for _repo in $(repos); do
|
||||
echo "<b>$_repo</b> · $(repo_summary "$_repo")"
|
||||
if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then
|
||||
printf '%s' "$_json" | jq -r '
|
||||
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
|
||||
def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)];
|
||||
sort_by(.time) | .[-10:] | reverse | .[] |
|
||||
"<code>\(.time[5:16] | sub("T"; " "))</code> игроков \(tagval("players:")), партий \(tagval("matches:"))"
|
||||
+ (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)'
|
||||
else
|
||||
echo "репозиторий недоступен или занят — повторите позже"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
}
|
||||
|
||||
tg_allowed() { # tg_allowed <chat_id> — чат из белого списка владельца
|
||||
for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
tg_reply() { # tg_reply <chat_id> <текст команды>
|
||||
# Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота.
|
||||
case "$2" in
|
||||
/backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;;
|
||||
/status*) _reply="<pre>$(cmd_status 2>&1 | head -n 60 | tg_escape)</pre>" ;;
|
||||
*) _reply="$(tg_help_text)" ;;
|
||||
esac
|
||||
[ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup."
|
||||
tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен."
|
||||
}
|
||||
|
||||
# Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие
|
||||
# пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение
|
||||
# restic совместимо с идущим бэкапом.
|
||||
cmd_telegram_bot() {
|
||||
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота."
|
||||
_offset_file="$STATE_DIR/telegram.offset"
|
||||
_updates="$RUNTIME_DIR/telegram-updates"
|
||||
log "Telegram-бот: слушаю команды /backups, /status, /help."
|
||||
while :; do
|
||||
_offset="$(cat "$_offset_file" 2>/dev/null || echo 0)"
|
||||
if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \
|
||||
--data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then
|
||||
sleep 30
|
||||
continue
|
||||
fi
|
||||
# Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются.
|
||||
printf '%s' "$_resp" | jq -r '.result[]? |
|
||||
[.update_id, (.message.chat.id // ""), (.message.from.username // ""),
|
||||
((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \
|
||||
> "$_updates" 2>/dev/null || { sleep 30; continue; }
|
||||
while IFS="$(printf '\037')" read -r _uid _chat _from _text; do
|
||||
echo $((_uid + 1)) > "$_offset_file"
|
||||
[ -n "$_chat" ] || continue
|
||||
if tg_allowed "$_chat"; then
|
||||
tg_reply "$_chat" "$_text"
|
||||
else
|
||||
log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID."
|
||||
fi
|
||||
done < "$_updates"
|
||||
done
|
||||
}
|
||||
|
||||
# Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не
|
||||
# подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он
|
||||
# забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»).
|
||||
cmd_telegram_chats() {
|
||||
[ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN."
|
||||
_resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \
|
||||
|| die "Telegram не ответил (неверный токен или нет сети)."
|
||||
_chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null)
|
||||
| "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')"
|
||||
if [ -z "$_chats" ]; then
|
||||
echo "Сообщений боту нет: напишите ему /start в Telegram и повторите."
|
||||
return 0
|
||||
fi
|
||||
echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):"
|
||||
printf '%s\n' "$_chats"
|
||||
}
|
||||
|
||||
cmd_telegram() {
|
||||
case "${1:-}" in
|
||||
bot) cmd_telegram_bot ;;
|
||||
chats) cmd_telegram_chats ;;
|
||||
test)
|
||||
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)."
|
||||
_sent=0
|
||||
for _chat in $(tg_chat_ids); do
|
||||
if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then
|
||||
_sent=$((_sent + 1))
|
||||
log "Telegram: тестовое сообщение в чат $_chat отправлено."
|
||||
else
|
||||
log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)."
|
||||
fi
|
||||
done
|
||||
[ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло."
|
||||
;;
|
||||
*) die "telegram: укажите chats, test или bot" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
||||
@@ -806,6 +1039,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из
|
||||
export <id|latest> [--repo local|vps] > fs.tar
|
||||
снимок в tar без сжатия (нужен exec -T)
|
||||
|
||||
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
|
||||
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
|
||||
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
|
||||
telegram bot бот-слушатель команд (контейнер запускает его сам)
|
||||
|
||||
Прочее:
|
||||
init создать репозитории (делается автоматически)
|
||||
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
||||
@@ -833,6 +1071,7 @@ case "$_cmd" in
|
||||
restic) cmd_restic "$@" ;;
|
||||
health) cmd_health "$@" ;;
|
||||
has-snapshots) cmd_has_snapshots "$@" ;;
|
||||
telegram) cmd_telegram "$@" ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
+57
-6
@@ -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,
|
||||
«Лимиты памяти».
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||
#
|
||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
|
||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
||||
#
|
||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||
@@ -38,10 +38,10 @@
|
||||
-Server
|
||||
}
|
||||
|
||||
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая
|
||||
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с
|
||||
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать
|
||||
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61).
|
||||
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки: строгая политика легко
|
||||
# ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с telegram.org + iframe
|
||||
# oauth.telegram.org) и EventSource (/api/events). Раскомментировать после проверки,
|
||||
# что вход и реал-тайм работают (#61).
|
||||
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||
|
||||
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||
|
||||
+15
-10
@@ -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), это ожидаемо.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Локальный «клон прода» в контейнере — для проверки прод-сборки на своей машине
|
||||
# (а не на Raspberry Pi). Тот же образ, что и прод, изолированные тома, единый .env.
|
||||
# Портов на хост НЕТ: тест виден ТОЛЬКО снаружи на https://forbidden-stars.ru через
|
||||
# сервис tunnel (контейнер ssh -R 9001:app:8000 на VPS).
|
||||
#
|
||||
# Запуск (обычно лаунчером при APP_ENV=test): docker compose -f docker-compose.test.yml up --build -d
|
||||
# Остановить и стереть данные: docker compose -f docker-compose.test.yml down -v
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: forbidden-stars:test
|
||||
container_name: forbidden-stars-test
|
||||
restart: "no"
|
||||
init: true
|
||||
env_file:
|
||||
- .env # единый .env (тот же, что у dev/prod); секреты не в git
|
||||
environment:
|
||||
# Окружение test: прод-клон, но отличимый от прода (см. config.is_test).
|
||||
# Форсим здесь, чтобы значение не зависело от APP_ENV внутри .env.
|
||||
APP_ENV: test
|
||||
# Портов на хост НЕТ: тест доступен только изнутри сети compose; наружу — через
|
||||
# сервис tunnel (ниже) на forbidden-stars.ru. Из LAN/localhost — недоступно.
|
||||
volumes:
|
||||
- db-data-test:/data # изолированные тестовые данные
|
||||
- uploads-data-test:/data/uploads
|
||||
- achievements-data-test:/data/achievements # определения ачивок (файлы)
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
mem_limit: 512m
|
||||
|
||||
# SSH reverse-туннель к VPS: forbidden-stars.ru (VPS:9001) -> app:8000 (по сети compose).
|
||||
# Ключ — ./deploy/tunnel/id_tunnel (gitignore), pubkey в authorized_keys у tunnel@VPS.
|
||||
# Внимание: слот 9001 общий с dev-туннелем (run.ps1); поднимай что-то одно за раз.
|
||||
tunnel:
|
||||
build: ./deploy/tunnel
|
||||
image: forbidden-stars-tunnel:test
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST}
|
||||
VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel}
|
||||
VPS_TUNNEL_PORT: "9001" # forbidden-stars.ru (dev/test)
|
||||
UPSTREAM: app:8000
|
||||
volumes:
|
||||
- ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro
|
||||
mem_limit: 64m
|
||||
|
||||
# Бэкапы тест-клона: тот же образ, что у прода (сборка локально), но ТОЛЬКО локальный
|
||||
# репозиторий и без расписания. BACKUP_VPS_HOST принудительно пуст — тестовые данные
|
||||
# никогда не попадут в прод-репозиторий на VPS. Учебное восстановление:
|
||||
# .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar
|
||||
backup:
|
||||
build: ./deploy/backup
|
||||
image: forbidden-stars-backup:test
|
||||
restart: "no"
|
||||
environment:
|
||||
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-test-contour-only}
|
||||
BACKUP_HOSTNAME: fs-test
|
||||
BACKUP_SCHEDULE: ""
|
||||
BACKUP_VERIFY_SCHEDULE: ""
|
||||
BACKUP_VPS_HOST: ""
|
||||
BACKUP_COMPRESSION: ${BACKUP_COMPRESSION:-max}
|
||||
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
||||
volumes:
|
||||
- db-data-test:/fs-db
|
||||
- uploads-data-test:/fs/uploads
|
||||
- achievements-data-test:/fs/achievements
|
||||
- backup-data-test:/backup
|
||||
mem_limit: 384m
|
||||
|
||||
volumes:
|
||||
db-data-test:
|
||||
uploads-data-test:
|
||||
achievements-data-test:
|
||||
backup-data-test:
|
||||
+12
-6
@@ -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 # живая БД (снимается консистентно)
|
||||
|
||||
@@ -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, «Калибровка»).
|
||||
@@ -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()
|
||||
+19
-1
@@ -1,15 +1,33 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/router";
|
||||
import { Spinner } from "./components/Spinner";
|
||||
import { ToastProvider } from "./context/ToastContext";
|
||||
import { setAppTzOffsetHours } from "./domain/format";
|
||||
import { useAuthConfig } from "./hooks/auth";
|
||||
|
||||
/**
|
||||
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
|
||||
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
|
||||
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
|
||||
*/
|
||||
function AppTimeZone({ children }: { children: ReactNode }) {
|
||||
const { data, isPending } = useAuthConfig();
|
||||
if (data) setAppTzOffsetHours(data.tz_offset_hours);
|
||||
if (isPending) return <Spinner />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
<AppTimeZone>
|
||||
<RouterProvider router={router} />
|
||||
</AppTimeZone>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+79
-15
@@ -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: {
|
||||
|
||||
@@ -30,8 +30,14 @@ function Row({
|
||||
</div>
|
||||
</div>
|
||||
{/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только
|
||||
подтверждённый рейтинг. */}
|
||||
<div className={"lb-score" + (provisional || entry.score == null ? " provisional" : "")}>
|
||||
подтверждённый рейтинг. Рейтинг общий, поэтому подтверждённость приходит
|
||||
флагом: в группе «Ещё не играли» бывают и новички, и опытные игроки. */}
|
||||
<div
|
||||
className={
|
||||
"lb-score" +
|
||||
(provisional || entry.score == null || !entry.rating_confirmed ? " provisional" : "")
|
||||
}
|
||||
>
|
||||
{entry.score ?? "—"}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
key={m.id}
|
||||
className="card row-between"
|
||||
className="card"
|
||||
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
>
|
||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
||||
(mine?.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
||||
</span>
|
||||
<span
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{mine?.faction_name ?? "—"}
|
||||
<div className="row-between">
|
||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
||||
(mine?.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
||||
</span>
|
||||
<span
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{mine?.faction_name ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||
</span>
|
||||
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
|
||||
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom";
|
||||
import { formatDate, formatDuration } from "../domain/format";
|
||||
import { winReasonLabel } from "../domain/winReasons";
|
||||
import type { MatchListItem } from "../domain/types";
|
||||
import { RatingDelta } from "./RatingDelta";
|
||||
|
||||
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
||||
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
||||
* отфильтрованной по status="finished". */
|
||||
* отфильтрованной по status="finished". В истории профиля у партии есть rating_delta
|
||||
* владельца — она идёт последней строкой; у списка группы её нет. */
|
||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
const navigate = useNavigate();
|
||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||
@@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { PickerSelect } from "./PickerSelect";
|
||||
import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
|
||||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||
const NO_ROUND = 0;
|
||||
|
||||
/**
|
||||
* Итог партии в форме завершения и правки результатов: причина победы, раунд окончания
|
||||
* и предупреждения о несогласованном вводе. «Последний выживший» не выбирается —
|
||||
* пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors).
|
||||
*/
|
||||
export function MatchOutcomeFields({
|
||||
winReason,
|
||||
onReason,
|
||||
endRound,
|
||||
onEndRound,
|
||||
maxRounds,
|
||||
warnings,
|
||||
}: {
|
||||
winReason: WinReason | null;
|
||||
onReason: (reason: WinReason) => void;
|
||||
endRound: number | null;
|
||||
onEndRound: (round: number | null) => void;
|
||||
maxRounds: number;
|
||||
warnings: string[];
|
||||
}) {
|
||||
const roundOptions = [
|
||||
{ id: NO_ROUND, label: "не указан" },
|
||||
...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })),
|
||||
];
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Итог партии</h3>
|
||||
<div className="field">
|
||||
<label className="label">Причина победы</label>
|
||||
{winReason === LAST_STANDING ? (
|
||||
<div className="picker-trigger" aria-disabled>
|
||||
{winReasonLabel(LAST_STANDING)}
|
||||
<span className="muted small" style={{ marginLeft: "auto" }}>
|
||||
все соперники выбыли
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<PickerSelect
|
||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— выберите причину —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => onReason(o.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="label">Раунд окончания (из {maxRounds})</label>
|
||||
<PickerSelect
|
||||
selected={roundOptions.find((o) => o.id === (endRound ?? NO_ROUND)) ?? null}
|
||||
options={roundOptions}
|
||||
placeholder="не указан"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => onEndRound(o.id === NO_ROUND ? null : o.id)}
|
||||
/>
|
||||
</div>
|
||||
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||
Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа
|
||||
весит больше.
|
||||
</p>
|
||||
{warnings.length > 0 && (
|
||||
<ul className="warn-list">
|
||||
{warnings.map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { Avatar } from "./Avatar";
|
||||
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
||||
|
||||
export interface PlacePlayer {
|
||||
user_id: number;
|
||||
@@ -10,6 +11,10 @@ export interface PlacePlayer {
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export type CountField = "objectives" | "worlds";
|
||||
/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */
|
||||
export type Counts = Record<number, { objectives: number | null; worlds: number | null }>;
|
||||
|
||||
type Target =
|
||||
| { type: "merge"; idx: number }
|
||||
| { type: "insert"; idx: number }
|
||||
@@ -28,7 +33,8 @@ interface DragState {
|
||||
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
|
||||
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
|
||||
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
|
||||
* выбывший. В каждом блоке — строка комментария об игроке.
|
||||
* выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
|
||||
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
|
||||
*
|
||||
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
|
||||
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
|
||||
@@ -41,6 +47,8 @@ export function PlaceEditor({
|
||||
comments,
|
||||
onChange,
|
||||
onComment,
|
||||
counts,
|
||||
onCount,
|
||||
}: {
|
||||
players: PlacePlayer[];
|
||||
blocks: number[][];
|
||||
@@ -48,6 +56,8 @@ export function PlaceEditor({
|
||||
comments: Record<number, string>;
|
||||
onChange: (blocks: number[][], eliminated: number[]) => void;
|
||||
onComment: (userId: number, text: string) => void;
|
||||
counts?: Counts;
|
||||
onCount?: (userId: number, field: CountField, value: number | null) => void;
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const elimRef = useRef<HTMLDivElement>(null);
|
||||
@@ -225,15 +235,41 @@ export function PlaceEditor({
|
||||
);
|
||||
};
|
||||
|
||||
const commentInput = (userId: number) => (
|
||||
<input
|
||||
className="rank-comment"
|
||||
placeholder="Комментарий об игроке"
|
||||
value={comments[userId] ?? ""}
|
||||
onChange={(e) => onComment(userId, e.target.value)}
|
||||
/>
|
||||
const countInput = (userId: number, field: CountField, label: string, locked = false) => (
|
||||
<label className="rank-count">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={MAX_COUNT}
|
||||
placeholder="—"
|
||||
disabled={locked}
|
||||
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
||||
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
const commentInput = (userId: number) => {
|
||||
const comment = (
|
||||
<input
|
||||
className="rank-comment"
|
||||
placeholder="Комментарий об игроке"
|
||||
value={comments[userId] ?? ""}
|
||||
onChange={(e) => onComment(userId, e.target.value)}
|
||||
/>
|
||||
);
|
||||
if (!counts) return comment;
|
||||
return (
|
||||
<div className="rank-details">
|
||||
{comment}
|
||||
{countInput(userId, "objectives", "цели")}
|
||||
{countInput(userId, "worlds", "миры", eliminated.includes(userId))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rank-list" ref={listRef}>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ProfileStatsCard({
|
||||
<b>{o.avg_place ?? "—"}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Очки (рейтинг)</div>
|
||||
<div>Рейтинг</div>
|
||||
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
|
||||
</div>
|
||||
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { formatRatingDelta } from "../domain/format";
|
||||
|
||||
/** Строка карточки истории: сколько общего рейтинга владелец профиля получил или
|
||||
* потерял за партию. Рост — зелёным, падение — красным, ноль — без цвета. */
|
||||
export function RatingDelta({ delta }: { delta: number }) {
|
||||
const text = formatRatingDelta(delta);
|
||||
const tone = text.startsWith("+") ? "rating-up" : text.startsWith("−") ? "rating-down" : undefined;
|
||||
return (
|
||||
<div className="small rating-delta">
|
||||
Рейтинг: <b className={tone}>{text}</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
|
||||
const APP_TZ_OFFSET_MIN = 3 * 60;
|
||||
// Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
|
||||
// Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
|
||||
// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
|
||||
// 3 ч (МСК) — запасное значение, если конфиг недоступен.
|
||||
let appTzOffsetMin = 3 * 60;
|
||||
|
||||
export function setAppTzOffsetHours(hours: number): void {
|
||||
appTzOffsetMin = hours * 60;
|
||||
}
|
||||
|
||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
|
||||
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
|
||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
|
||||
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
|
||||
function shifted(iso: string): Date {
|
||||
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
||||
return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
|
||||
}
|
||||
|
||||
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
||||
@@ -18,6 +25,14 @@ export function plural(n: number, one: string, few: string, many: string): strin
|
||||
return many;
|
||||
}
|
||||
|
||||
// Изменение рейтинга за партию: всегда со знаком и одним знаком после запятой
|
||||
// («+12.3», «−4.1», «0.0»); минус — типографский, как в справке.
|
||||
export function formatRatingDelta(delta: number): string {
|
||||
const abs = Math.abs(delta).toFixed(1);
|
||||
if (abs === "0.0") return "0.0";
|
||||
return `${delta > 0 ? "+" : "−"}${abs}`;
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number | null | undefined): string {
|
||||
if (minutes == null) return "—";
|
||||
const h = Math.floor(minutes / 60);
|
||||
@@ -42,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string {
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
||||
}
|
||||
|
||||
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё
|
||||
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
|
||||
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
|
||||
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
|
||||
export function toAppLocalInput(iso: string): string {
|
||||
const d = shifted(iso);
|
||||
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// Обратно: «настенное» время +3 из datetime-local → момент ISO (UTC). null — поле пустое.
|
||||
// Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
|
||||
// null — поле пустое.
|
||||
export function fromAppLocalInput(value: string): string | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
|
||||
if (!m) return null;
|
||||
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - APP_TZ_OFFSET_MIN * 60_000;
|
||||
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import type { components } from "../api/schema";
|
||||
|
||||
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
|
||||
|
||||
// Фиксированный порядок (сверху вниз).
|
||||
export const LAST_STANDING: WinReason = "last_standing";
|
||||
|
||||
// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший»
|
||||
// сюда не входит: она ставится сама, когда невыбывший игрок остался один.
|
||||
export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
||||
{ code: "objectives", label: "По целям" },
|
||||
{ code: "worlds", label: "По мирам" },
|
||||
@@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
||||
{ code: "resources", label: "По ресурсам" },
|
||||
];
|
||||
|
||||
const LABELS: Record<string, string> = Object.fromEntries(
|
||||
WIN_REASONS.map((w) => [w.code, w.label]),
|
||||
);
|
||||
const LABELS: Record<string, string> = {
|
||||
...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])),
|
||||
[LAST_STANDING]: "Последний выживший",
|
||||
};
|
||||
|
||||
export function winReasonLabel(code: string | null | undefined): string {
|
||||
return code ? LABELS[code] ?? code : "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно
|
||||
* один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда
|
||||
* выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать.
|
||||
*/
|
||||
export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null {
|
||||
if (survivors === 1) return LAST_STANDING;
|
||||
return current === LAST_STANDING ? null : current;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||
import { useMe } from "./auth";
|
||||
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||
|
||||
interface ServerEvent {
|
||||
type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements";
|
||||
/** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
|
||||
type:
|
||||
| "match"
|
||||
| "match_draft"
|
||||
| "ratings"
|
||||
| "group"
|
||||
| "invitations"
|
||||
| "notifications"
|
||||
| "announcements";
|
||||
match_id?: number;
|
||||
group_id?: number;
|
||||
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
|
||||
participant_ids?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,11 +24,6 @@ interface ServerEvent {
|
||||
*/
|
||||
export function useServerEvents(enabled: boolean) {
|
||||
const qc = useQueryClient();
|
||||
const { data: me } = useMe();
|
||||
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
|
||||
// SSE-соединение каждый раз, когда профиль перезапрашивается.
|
||||
const myId = useRef<number | null>(null);
|
||||
myId.current = me?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
@@ -50,26 +50,12 @@ export function useServerEvents(enabled: boolean) {
|
||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||
if (ev.group_id != null) {
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
||||
}
|
||||
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||
if (ev.participant_ids) {
|
||||
// Личные витрины — только у игравших: иначе каждая партия в группе
|
||||
// заставляла бы всех остальных перезапрашивать свою историю.
|
||||
for (const pid of ev.participant_ids) {
|
||||
qc.invalidateQueries({ queryKey: qk.userMatches(pid) });
|
||||
qc.invalidateQueries({ queryKey: qk.publicProfile(pid) });
|
||||
}
|
||||
if (myId.current != null && ev.participant_ids.includes(myId.current)) {
|
||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||
}
|
||||
} else {
|
||||
// Событие от бэкенда без списка участников (вкладка открыта до обновления
|
||||
// сервера) — ведём себя как раньше, широко.
|
||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
// Рейтинг общий и считается по всей истории: партия двигает топ, историю
|
||||
// и профили всех, кто играл после неё, и страницы других групп.
|
||||
invalidateRatingViews(qc);
|
||||
} else if (ev.type === "ratings") {
|
||||
invalidateRatingViews(qc);
|
||||
} else if (ev.type === "group") {
|
||||
if (ev.group_id != null) {
|
||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
useGroup,
|
||||
useGroupMembers,
|
||||
useRemoveMember,
|
||||
useRenameGroup,
|
||||
useSetGroupExpansions,
|
||||
useUpdateGroup,
|
||||
} from "../hooks/groups";
|
||||
import { useExpansions } from "../hooks/reference";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GroupSettingsPage() {
|
||||
const { data: expansions } = useExpansions();
|
||||
const { data: members } = useGroupMembers(groupId);
|
||||
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
||||
const renameGroup = useRenameGroup(groupId ?? 0);
|
||||
const updateGroup = useUpdateGroup(groupId ?? 0);
|
||||
const removeMember = useRemoveMember(groupId ?? 0);
|
||||
const toast = useToast();
|
||||
|
||||
@@ -55,7 +55,7 @@ export function GroupSettingsPage() {
|
||||
const trimmed = nameValue.trim();
|
||||
if (!trimmed || trimmed === group.name) return;
|
||||
try {
|
||||
await renameGroup.mutateAsync(trimmed);
|
||||
await updateGroup.mutateAsync({ name: trimmed });
|
||||
setName(null);
|
||||
toast.show("Название сохранено");
|
||||
} catch (e) {
|
||||
@@ -63,6 +63,15 @@ export function GroupSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveNineRounds = async (enabled: boolean) => {
|
||||
try {
|
||||
await updateGroup.mutateAsync({ nine_rounds_rule: enabled });
|
||||
toast.show(enabled ? "9 раундов при 5–6 игроках включено" : "Правило 9 раундов выключено");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const saveExpansions = async () => {
|
||||
try {
|
||||
await setExpansions.mutateAsync([...selected]);
|
||||
@@ -95,7 +104,7 @@ export function GroupSettingsPage() {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={saveName}
|
||||
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
|
||||
disabled={updateGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
@@ -127,6 +136,22 @@ export function GroupSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Домашние правила</h3>
|
||||
<label className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>9 раундов при 5–6 игроках</span>
|
||||
<Switch
|
||||
checked={group.nine_rounds_rule}
|
||||
disabled={updateGroup.isPending}
|
||||
onChange={saveNineRounds}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||
Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных
|
||||
партий не меняется.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}
|
||||
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
|
||||
|
||||
+102
-32
@@ -2,8 +2,9 @@ import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг»
|
||||
* (как считаются очки игроков). Текст соответствует backend/app/services/scoring.py.
|
||||
* Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг».
|
||||
* Текст соответствует backend/app/services/scoring.py и docs/rating/rating-system.md:
|
||||
* коэффициенты, пороги и примеры менять вместе с ними.
|
||||
*/
|
||||
export function HelpPage() {
|
||||
const [showRating, setShowRating] = useState(false);
|
||||
@@ -32,50 +33,118 @@ export function HelpPage() {
|
||||
{showRating && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Очки игроков (рейтинг)</h3>
|
||||
<h3>Рейтинг игроков</h3>
|
||||
<p className="small">
|
||||
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые
|
||||
в зачёт не идут.
|
||||
Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает
|
||||
с <b>1500</b>. Разница в <b>400</b> пунктов означает шансы 10 к 1 в пользу более
|
||||
сильного.
|
||||
</p>
|
||||
<p className="small">
|
||||
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков
|
||||
за столом, поэтому победа за большим столом ценится выше, чем за маленьким:
|
||||
Считаются только <b>завершённые</b> партии, по порядку их игры. Правка или удаление
|
||||
старой партии пересчитывает и все последующие.
|
||||
</p>
|
||||
<div className="formula">
|
||||
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1)
|
||||
<p className="small">
|
||||
Партия раскладывается на пары игроков. В каждой паре фактический результат (выше —
|
||||
1, поровну — 0.5, ниже — 0) сравнивается с ожидаемым по рейтингам до партии:
|
||||
</p>
|
||||
<div className="formula">ожидание = 1 / (1 + 10^((R соперника − R игрока) / 400))</div>
|
||||
<div className="formula" style={{ marginTop: 6 }}>
|
||||
ΔR = K × G / (N − 1) × Σ M × (результат − ожидание)
|
||||
</div>
|
||||
<p className="small" style={{ marginTop: 10 }}>
|
||||
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</b>.
|
||||
</p>
|
||||
<ul className="small" style={{ marginTop: 0, paddingLeft: 18 }}>
|
||||
<li>Если несколько игроков делят место (ничья) — они делят сумму очков поровну.</li>
|
||||
<li>Выбывшие из партии считаются как последнее место.</li>
|
||||
</ul>
|
||||
<p className="small">
|
||||
Итоговый <b>рейтинг</b> игрока — сглаженное среднее: к реальным партиям
|
||||
«дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100):
|
||||
</p>
|
||||
<div className="formula">
|
||||
рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100
|
||||
</div>
|
||||
<p className="small" style={{ marginTop: 10 }}>
|
||||
Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному
|
||||
среднему — стабильные результаты на длинной дистанции ценятся выше короткой
|
||||
удачной серии.
|
||||
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
||||
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
||||
делят последнее место, но между собой не сравниваются: в этой партии все они
|
||||
проиграли, так что пара двух выбывших рейтинг не двигает.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Пример (стол на 4 игроков)</h3>
|
||||
<h3>От чего зависит изменение</h3>
|
||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
<li>
|
||||
<b>K — скорость.</b> У новичка 64, к 20-й партии плавно снижается до 16: новичок
|
||||
быстро находит свой уровень, а рейтинг опытного игрока не скачет.
|
||||
</li>
|
||||
<li>
|
||||
<b>G — размер стола:</b> 1 + 0.5 × (N − 2) / 4. В дуэли 1.0, вчетвером 1.25,
|
||||
вшестером 1.5.
|
||||
</li>
|
||||
<li>
|
||||
<b>M — отрыв в паре</b>, от 0.5 до 2. Обычная партия даёт 1. Больше, если:
|
||||
<ul style={{ paddingLeft: 16 }}>
|
||||
<li>
|
||||
партия закончилась раньше — для пар с победителем; обычной считается конец
|
||||
в предпоследнем раунде;
|
||||
</li>
|
||||
<li>разница целей велика относительно числа игроков;</li>
|
||||
<li>разница миров велика относительно доли миров на игрока.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>Тип победы</b> — для пар с победителем, чем ближе партия, тем меньше:
|
||||
по целям ×1, по мирам ×0.85, по пластику ×0.7, по ресурсам ×0.6,
|
||||
последний выживший ×1.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="small" style={{ marginTop: 10 }}>
|
||||
Раунд окончания, цели и миры вводить <b>необязательно</b>: пропущенное считается
|
||||
обычным значением и множитель не меняет. «Последний выживший» ставится сам, когда
|
||||
все соперники выбыли, — отрыв по целям тогда максимальный. Лимит раундов — 8,
|
||||
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
||||
</p>
|
||||
<p className="small" style={{ marginBottom: 0 }}>
|
||||
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
|
||||
и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются
|
||||
между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Примеры (опытные игроки)</h3>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="row-between small"><span>1-е место</span><b>(4−1−0)/3 = 1.00 → 100</b></div>
|
||||
<div className="row-between small"><span>2-е место</span><b>(4−2−0)/3 = 0.67 → 67</b></div>
|
||||
<div className="row-between small"><span>3-е место</span><b>(4−3−0)/3 = 0.33 → 33</b></div>
|
||||
<div className="row-between small"><span>4-е место</span><b>(4−4−0)/3 = 0.00 → 0</b></div>
|
||||
<div className="row-between small">
|
||||
<span>Ничья за 1-е (двое)</span><b>(4−1−0.5)/3 = 0.83 каждому</b>
|
||||
<span>Дуэль 1600 и 1400: победил сильный</span>
|
||||
<b>+3.8 / −3.8</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Дуэль 1600 и 1400: победил слабый</span>
|
||||
<b>+12.2 / −12.2</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Равные, победа по целям без деталей</span>
|
||||
<b>+8.0</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Равные, победа по мирам без деталей</span>
|
||||
<b>+6.8</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Разгром на 3-м раунде (цели 2:0, миры 8:2)</span>
|
||||
<b>+16.0</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>1-е место за столом на 5 равных</span>
|
||||
<b>+11.0</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Новичок обыграл равного опытного</span>
|
||||
<b>+32 / −8</b>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||
Рейтинг показывается целым числом, но считается без округления.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Один рейтинг на всё приложение</h3>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый
|
||||
в общем топе, в профиле и на странице группы. На странице группы по партиям этой
|
||||
группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус
|
||||
«Новичок» там общие.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -91,6 +160,7 @@ export function HelpPage() {
|
||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
|
||||
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
|
||||
<li><b>Лучшая партия</b> в профиле — та, что принесла больше всего рейтинга.</li>
|
||||
<li>
|
||||
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
||||
и затем по нику.
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ApiError } from "../api/client";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { MatchMedia } from "../components/MatchMedia";
|
||||
import { MatchOutcomeFields } from "../components/MatchOutcomeFields";
|
||||
import { PickerSelect } from "../components/PickerSelect";
|
||||
import { PlaceEditor } from "../components/PlaceEditor";
|
||||
import { type CountField, type Counts, PlaceEditor } from "../components/PlaceEditor";
|
||||
import { PlayerLink } from "../components/PlayerLink";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings";
|
||||
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
||||
import type { MatchFinishDraftData } from "../domain/types";
|
||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
import type { MatchFinishDraftData, MatchRead } from "../domain/types";
|
||||
import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import {
|
||||
@@ -24,14 +26,50 @@ import {
|
||||
} from "../hooks/matches";
|
||||
import { useGroupFactions } from "../hooks/reference";
|
||||
|
||||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||
type Participant = MatchRead["participants"][number];
|
||||
|
||||
const countsOf = (participants: Participant[]): Counts =>
|
||||
Object.fromEntries(
|
||||
participants.map((p) => [p.user_id, { objectives: p.objectives ?? null, worlds: p.worlds ?? null }]),
|
||||
);
|
||||
|
||||
const countsFromDraft = (participants: Participant[], data: MatchFinishDraftData): Counts =>
|
||||
Object.fromEntries(
|
||||
participants.map((p) => [
|
||||
p.user_id,
|
||||
{
|
||||
objectives: data.objectives?.[String(p.user_id)] ?? null,
|
||||
worlds: data.worlds?.[String(p.user_id)] ?? null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Черновик хранит только заполненные поля: {user_id строкой: число}.
|
||||
const countDict = (counts: Counts, field: CountField): Record<string, number> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(counts).flatMap(([uid, c]) => (c[field] == null ? [] : [[uid, c[field]]])),
|
||||
);
|
||||
|
||||
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
||||
|
||||
// Места по раскладке: блоки сверху вниз (competition ranking — ничья съедает следующие
|
||||
// места), затем выбывшие с общим последним местом.
|
||||
const placeRows = (blocks: number[][], eliminated: number[]) => {
|
||||
let place = 1;
|
||||
const survivors = blocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => ({ uid, place, eliminated: false }));
|
||||
place += ids.length;
|
||||
return rows;
|
||||
});
|
||||
return [...survivors, ...eliminated.map((uid) => ({ uid, place, eliminated: true }))];
|
||||
};
|
||||
|
||||
export function MatchDetailPage() {
|
||||
const { matchId } = useParams();
|
||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||
const parsed = matchId ? Number(matchId) : NaN;
|
||||
const id = Number.isInteger(parsed) ? parsed : null;
|
||||
const { data: match, isLoading, refetch } = useMatch(id);
|
||||
const { data: match, isLoading, error: loadError, refetch } = useMatch(id);
|
||||
const { data: me } = useMe();
|
||||
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||
const finish = useFinishMatch();
|
||||
@@ -47,7 +85,10 @@ export function MatchDetailPage() {
|
||||
const [blocks, setBlocks] = useState<number[][] | null>(null);
|
||||
const [elim, setElim] = useState<number[]>([]);
|
||||
const [comments, setComments] = useState<Record<number, string> | null>(null);
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [counts, setCounts] = useState<Counts | null>(null);
|
||||
// null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors).
|
||||
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
|
||||
const [endRound, setEndRound] = useState<number | null>(null);
|
||||
const [overall, setOverall] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
@@ -86,23 +127,38 @@ export function MatchDetailPage() {
|
||||
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
||||
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
||||
const incoming = match?.finish_draft;
|
||||
const participants = match?.participants;
|
||||
useEffect(() => {
|
||||
if (!incoming || match?.status !== "in_progress") return;
|
||||
if (!incoming || !participants || match?.status !== "in_progress") return;
|
||||
if (incoming.updated_by === me?.id) return;
|
||||
if (incoming.updated_at === appliedDraftAt.current) return;
|
||||
if (Date.now() - lastLocalEdit.current < 1500) return;
|
||||
setBlocks(incoming.data.blocks);
|
||||
setElim(incoming.data.eliminated);
|
||||
setComments(incoming.data.comments);
|
||||
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason);
|
||||
setCounts(countsFromDraft(participants, incoming.data));
|
||||
setWinReason(incoming.data.win_reason ?? null);
|
||||
setEndRound(incoming.data.end_round ?? null);
|
||||
setOverall(incoming.data.overall_comment ?? "");
|
||||
appliedDraftAt.current = incoming.updated_at;
|
||||
}, [incoming, match?.status, me?.id]);
|
||||
}, [incoming, participants, match?.status, me?.id]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
if (!match) {
|
||||
// Из истории чужого профиля можно попасть в партию группы, где зритель не состоит:
|
||||
// сервер отдаёт 403, и «не найдена» здесь вводила бы в заблуждение.
|
||||
if (loadError instanceof ApiError && loadError.code === "NOT_GROUP_MEMBER") {
|
||||
return (
|
||||
<div className="muted">
|
||||
Партия сыграна в группе, в которой вы не состоите, — открыть её могут только
|
||||
участники группы.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="muted">Партия не найдена.</div>;
|
||||
}
|
||||
|
||||
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin)
|
||||
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
|
||||
const inProgress = match.status === "in_progress";
|
||||
|
||||
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
|
||||
@@ -110,6 +166,7 @@ export function MatchDetailPage() {
|
||||
const finishComments: Record<number, string> =
|
||||
comments ??
|
||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""]));
|
||||
const finishCounts: Counts = counts ?? countsOf(match.participants);
|
||||
|
||||
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
|
||||
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||||
@@ -123,44 +180,80 @@ export function MatchDetailPage() {
|
||||
),
|
||||
win_reason: winReason,
|
||||
overall_comment: overall.trim() || null,
|
||||
end_round: endRound,
|
||||
objectives: countDict(finishCounts, "objectives"),
|
||||
worlds: countDict(finishCounts, "worlds"),
|
||||
...patch,
|
||||
});
|
||||
|
||||
// Раскладка изменилась: при одном выжившем причина становится «последний выживший»,
|
||||
// при возврате второго — сбрасывается.
|
||||
const applyLayout = (b: number[][], e: number[]): WinReason | null => {
|
||||
const reason = reasonForSurvivors(winReason, survivorsIn(b));
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
setWinReason(reason);
|
||||
return reason;
|
||||
};
|
||||
|
||||
const applyCount = (uid: number, field: CountField, value: number | null): Counts => {
|
||||
const current = finishCounts[uid] ?? { objectives: null, worlds: null };
|
||||
const next = { ...finishCounts, [uid]: { ...current, [field]: value } };
|
||||
setCounts(next);
|
||||
return next;
|
||||
};
|
||||
|
||||
const outcomeSeats = (): OutcomeSeat[] => {
|
||||
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
||||
return placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||
userId: uid,
|
||||
nickname: byId.get(uid)?.nickname ?? "",
|
||||
place,
|
||||
eliminated,
|
||||
objectives: finishCounts[uid]?.objectives ?? null,
|
||||
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
||||
}));
|
||||
};
|
||||
|
||||
const warnings = () =>
|
||||
finishWarnings({
|
||||
seats: outcomeSeats(),
|
||||
winReason,
|
||||
endRound,
|
||||
maxRounds: match.max_rounds,
|
||||
});
|
||||
|
||||
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
|
||||
const resultRows = () =>
|
||||
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||
user_id: uid,
|
||||
place: eliminated ? null : place,
|
||||
eliminated,
|
||||
comment: (finishComments[uid] ?? "").trim() || null,
|
||||
objectives: finishCounts[uid]?.objectives ?? null,
|
||||
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||
}));
|
||||
|
||||
const submitFinish = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
if (!winReason) {
|
||||
setError("Выберите причину победы.");
|
||||
return;
|
||||
}
|
||||
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
||||
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
||||
if (draftTimer.current) {
|
||||
clearTimeout(draftTimer.current);
|
||||
sendDraft();
|
||||
}
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => ({
|
||||
user_id: uid,
|
||||
place,
|
||||
eliminated: false,
|
||||
comment: commentOf(uid),
|
||||
}));
|
||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||
return rows;
|
||||
});
|
||||
try {
|
||||
await finish.mutateAsync({
|
||||
matchId: id,
|
||||
body: {
|
||||
participants: [
|
||||
...survivors,
|
||||
...elim.map((uid) => ({
|
||||
user_id: uid,
|
||||
place: null,
|
||||
eliminated: true,
|
||||
comment: commentOf(uid),
|
||||
})),
|
||||
],
|
||||
participants: resultRows(),
|
||||
win_reason: winReason,
|
||||
end_round: endRound,
|
||||
overall_comment: overall.trim() || null,
|
||||
expected_version: match.version,
|
||||
},
|
||||
@@ -188,7 +281,9 @@ export function MatchDetailPage() {
|
||||
setComments(
|
||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
||||
);
|
||||
setWinReason((match.win_reason ?? "objectives") as WinReason);
|
||||
setCounts(countsOf(match.participants));
|
||||
setWinReason((match.win_reason ?? null) as WinReason | null);
|
||||
setEndRound(match.end_round ?? null);
|
||||
setOverall(match.overall_comment ?? "");
|
||||
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||||
setError(null);
|
||||
@@ -199,36 +294,31 @@ export function MatchDetailPage() {
|
||||
setEditing(false);
|
||||
setBlocks(null);
|
||||
setComments(null);
|
||||
setCounts(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
if (!winReason) {
|
||||
setError("Выберите причину победы.");
|
||||
return;
|
||||
}
|
||||
const wasRandom = Object.fromEntries(
|
||||
match.participants.map((p) => [p.user_id, p.was_random]),
|
||||
);
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
|
||||
user_id: uid,
|
||||
faction_id: editFactions[uid],
|
||||
place,
|
||||
eliminated,
|
||||
was_random: wasRandom[uid] ?? false,
|
||||
comment: commentOf(uid),
|
||||
});
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => rowOf(uid, place, false));
|
||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||
return rows;
|
||||
});
|
||||
try {
|
||||
await updateMatch.mutateAsync({
|
||||
matchId: id,
|
||||
body: {
|
||||
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
|
||||
participants: resultRows().map((r) => ({
|
||||
...r,
|
||||
faction_id: editFactions[r.user_id],
|
||||
was_random: wasRandom[r.user_id] ?? false,
|
||||
})),
|
||||
win_reason: winReason,
|
||||
end_round: endRound,
|
||||
overall_comment: overall.trim() || null,
|
||||
expected_version: match.version,
|
||||
},
|
||||
@@ -276,6 +366,13 @@ export function MatchDetailPage() {
|
||||
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||||
];
|
||||
|
||||
const placeHint = (
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы
|
||||
разделить место (ничья). Цели и миры — на конец партии.
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
@@ -294,6 +391,9 @@ export function MatchDetailPage() {
|
||||
{!inProgress && (
|
||||
<div className="small" style={{ marginTop: 4 }}>
|
||||
Победа: <b>{winReasonLabel(match.win_reason)}</b>
|
||||
{match.end_round != null && (
|
||||
<span className="muted"> · конец в {match.end_round}-м раунде из {match.max_rounds}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
||||
@@ -304,20 +404,16 @@ export function MatchDetailPage() {
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Правка результатов</h3>
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
{placeHint}
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
eliminated={elim}
|
||||
comments={finishComments}
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
}}
|
||||
counts={finishCounts}
|
||||
onChange={applyLayout}
|
||||
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
||||
onCount={applyCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -345,16 +441,14 @@ export function MatchDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Причина победы</h3>
|
||||
<PickerSelect
|
||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— причина —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => setWinReason(o.id)}
|
||||
/>
|
||||
</div>
|
||||
<MatchOutcomeFields
|
||||
winReason={winReason}
|
||||
onReason={setWinReason}
|
||||
endRound={endRound}
|
||||
onEndRound={setEndRound}
|
||||
maxRounds={match.max_rounds}
|
||||
warnings={warnings()}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<h3>О партии</h3>
|
||||
@@ -413,6 +507,11 @@ export function MatchDetailPage() {
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
{p.was_random && <span className="badge">🎲</span>}
|
||||
</PlayerLink>
|
||||
{(p.objectives != null || p.worlds != null) && (
|
||||
<div className="small muted" style={{ marginLeft: 32 }}>
|
||||
цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"}
|
||||
</div>
|
||||
)}
|
||||
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
|
||||
</div>
|
||||
))}
|
||||
@@ -448,10 +547,7 @@ export function MatchDetailPage() {
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Места</h3>
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
{placeHint}
|
||||
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||||
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||||
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||||
@@ -464,10 +560,10 @@ export function MatchDetailPage() {
|
||||
blocks={finishBlocks}
|
||||
eliminated={elim}
|
||||
comments={finishComments}
|
||||
counts={finishCounts}
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
queueDraft(draftOf({ blocks: b, eliminated: e }));
|
||||
const reason = applyLayout(b, e);
|
||||
queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason }));
|
||||
}}
|
||||
onComment={(uid, text) => {
|
||||
const next = { ...finishComments, [uid]: text };
|
||||
@@ -480,23 +576,33 @@ export function MatchDetailPage() {
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Причина победы</h3>
|
||||
<PickerSelect
|
||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— причина —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => {
|
||||
setWinReason(o.id);
|
||||
queueDraft(draftOf({ win_reason: o.id }));
|
||||
onCount={(uid, field, value) => {
|
||||
const next = applyCount(uid, field, value);
|
||||
queueDraft(
|
||||
draftOf({
|
||||
objectives: countDict(next, "objectives"),
|
||||
worlds: countDict(next, "worlds"),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MatchOutcomeFields
|
||||
winReason={winReason}
|
||||
onReason={(reason) => {
|
||||
setWinReason(reason);
|
||||
queueDraft(draftOf({ win_reason: reason }));
|
||||
}}
|
||||
endRound={endRound}
|
||||
onEndRound={(round) => {
|
||||
setEndRound(round);
|
||||
queueDraft(draftOf({ end_round: round }));
|
||||
}}
|
||||
maxRounds={match.max_rounds}
|
||||
warnings={warnings()}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<h3>О партии</h3>
|
||||
<textarea
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Spinner } from "../components/Spinner";
|
||||
import type { LeaderboardEntry } from "../domain/types";
|
||||
import { useLeaderboard } from "../hooks/stats";
|
||||
|
||||
type SortKey = "rank" | "games" | "wins" | "win_rate" | "score";
|
||||
// Столбца побед нет: четырёхзначный рейтинг иначе не помещается в строку, а доля побед
|
||||
// уже есть в WR.
|
||||
type SortKey = "rank" | "games" | "win_rate" | "score";
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||
@@ -50,7 +52,6 @@ export function OverallStatsPage() {
|
||||
<span className="nick">{e.nickname}</span>
|
||||
</span>
|
||||
<span className="st-num">{e.games}</span>
|
||||
<span className="st-num">{e.wins}</span>
|
||||
<span className="st-num">{pct(e.win_rate)}</span>
|
||||
<span className={"st-num lb-score" + (isProvisional || e.score == null ? " provisional" : "")}>
|
||||
{e.score ?? "—"}
|
||||
@@ -70,9 +71,8 @@ export function OverallStatsPage() {
|
||||
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
|
||||
<span>Игрок</span>
|
||||
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
|
||||
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
|
||||
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
|
||||
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button>
|
||||
<button onClick={() => toggleSort("score")}>Рейтинг{arrow("score")}</button>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="muted small" style={{ paddingTop: 8 }}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Spinner } from "../../components/Spinner";
|
||||
import { useToast } from "../../context/ToastContext";
|
||||
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
||||
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
||||
// поэтому в прод-сборке вырезается тришейкингом (как и dev-вход).
|
||||
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||
|
||||
export function AdminAccountsPage() {
|
||||
|
||||
@@ -5,7 +5,15 @@ import { ApiError } from "../../api/client";
|
||||
import { MatchMedia } from "../../components/MatchMedia";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { Switch } from "../../components/Switch";
|
||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||
import { finishWarnings } from "../../domain/finishWarnings";
|
||||
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
|
||||
import {
|
||||
LAST_STANDING,
|
||||
reasonForSurvivors,
|
||||
WIN_REASONS,
|
||||
type WinReason,
|
||||
winReasonLabel,
|
||||
} from "../../domain/winReasons";
|
||||
import {
|
||||
useAdminDeleteAttachment,
|
||||
useAdminFactions,
|
||||
@@ -23,6 +31,8 @@ interface Row {
|
||||
eliminated: boolean;
|
||||
was_random: boolean;
|
||||
comment: string;
|
||||
objectives: number | null;
|
||||
worlds: number | null;
|
||||
}
|
||||
|
||||
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||
@@ -37,7 +47,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [playedAt, setPlayedAt] = useState("");
|
||||
const [overall, setOverall] = useState("");
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
|
||||
const [endRound, setEndRound] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,19 +62,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
eliminated: p.eliminated,
|
||||
was_random: p.was_random,
|
||||
comment: p.comment ?? "",
|
||||
objectives: p.objectives ?? null,
|
||||
worlds: p.worlds ?? null,
|
||||
})),
|
||||
);
|
||||
setPlayedAt(match.played_at);
|
||||
setOverall(match.overall_comment ?? "");
|
||||
if (match.win_reason) setWinReason(match.win_reason);
|
||||
setWinReason(match.win_reason ?? null);
|
||||
setEndRound(match.end_round ?? null);
|
||||
}
|
||||
}, [match]);
|
||||
|
||||
const upd = (i: number, patch: Partial<Row>) =>
|
||||
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
const upd = (i: number, patch: Partial<Row>) => {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||
setRows(next);
|
||||
// Выбывание меняет число выживших: «последний выживший» ставится и снимается сам.
|
||||
if ("eliminated" in patch) {
|
||||
setWinReason(reasonForSurvivors(winReason, next.filter((r) => !r.eliminated).length));
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
if (!winReason) {
|
||||
setError("Выберите причину победы.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
matchId,
|
||||
@@ -71,6 +95,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
played_at: playedAt,
|
||||
overall_comment: overall.trim() || null,
|
||||
win_reason: winReason,
|
||||
end_round: endRound,
|
||||
participants: rows.map((r) => ({
|
||||
user_id: r.user_id,
|
||||
faction_id: r.faction_id,
|
||||
@@ -78,6 +103,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
eliminated: r.eliminated,
|
||||
was_random: r.was_random,
|
||||
comment: r.comment.trim() || null,
|
||||
objectives: r.objectives,
|
||||
worlds: r.eliminated ? null : r.worlds,
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -87,6 +114,22 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
}
|
||||
};
|
||||
|
||||
const warnings = match
|
||||
? finishWarnings({
|
||||
seats: rows.map((r) => ({
|
||||
userId: r.user_id,
|
||||
nickname: r.nickname,
|
||||
place: r.place,
|
||||
eliminated: r.eliminated,
|
||||
objectives: r.objectives,
|
||||
worlds: r.eliminated ? 0 : r.worlds,
|
||||
})),
|
||||
winReason,
|
||||
endRound,
|
||||
maxRounds: match.max_rounds,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
|
||||
<div
|
||||
@@ -159,23 +202,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
выбыл
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий об игроке"
|
||||
value={r.comment}
|
||||
onChange={(e) => upd(i, { comment: e.target.value })}
|
||||
/>
|
||||
<div className="row">
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Комментарий об игроке"
|
||||
value={r.comment}
|
||||
onChange={(e) => upd(i, { comment: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
style={{ flex: "0 0 70px" }}
|
||||
type="number"
|
||||
min={0}
|
||||
max={MAX_COUNT}
|
||||
placeholder="цели"
|
||||
title="Цели на конец партии"
|
||||
value={r.objectives ?? ""}
|
||||
onChange={(e) => upd(i, { objectives: parseCount(e.target.value) })}
|
||||
/>
|
||||
<input
|
||||
style={{ flex: "0 0 70px" }}
|
||||
type="number"
|
||||
min={0}
|
||||
max={MAX_COUNT}
|
||||
placeholder="миры"
|
||||
title="Миры на конец партии"
|
||||
disabled={r.eliminated}
|
||||
value={r.eliminated ? 0 : (r.worlds ?? "")}
|
||||
onChange={(e) => upd(i, { worlds: parseCount(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Причина победы</label>
|
||||
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}>
|
||||
{WIN_REASONS.map((w) => (
|
||||
<option key={w.code} value={w.code}>
|
||||
{w.label}
|
||||
{winReason === LAST_STANDING ? (
|
||||
<select value={LAST_STANDING} disabled>
|
||||
<option value={LAST_STANDING}>{winReasonLabel(LAST_STANDING)}</option>
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={winReason ?? ""}
|
||||
onChange={(e) => setWinReason(e.target.value as WinReason)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
— выберите причину —
|
||||
</option>
|
||||
{WIN_REASONS.map((w) => (
|
||||
<option key={w.code} value={w.code}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Раунд окончания (из {match.max_rounds})</label>
|
||||
<select
|
||||
value={endRound ?? ""}
|
||||
onChange={(e) => setEndRound(e.target.value === "" ? null : Number(e.target.value))}
|
||||
>
|
||||
<option value="">не указан</option>
|
||||
{Array.from({ length: match.max_rounds }, (_, k) => k + 1).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}-й
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{warnings.length > 0 && (
|
||||
<ul className="warn-list">
|
||||
{warnings.map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
|
||||
@@ -10,9 +10,9 @@ import { useToast } from "../../context/ToastContext";
|
||||
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
|
||||
*
|
||||
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда
|
||||
* (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль
|
||||
* (backend/app/routers/dev_admin.py, исключён из прод-образа). Этот модуль
|
||||
* рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
|
||||
* он не используется и вырезается тришейкингом — в прод/тест удаление недоступно.
|
||||
* он не используется и вырезается тришейкингом — в проде удаление недоступно.
|
||||
*/
|
||||
export function DevDeleteAccountButton({
|
||||
userId,
|
||||
@@ -39,7 +39,7 @@ export function DevDeleteAccountButton({
|
||||
<button
|
||||
className="btn small"
|
||||
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
|
||||
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
|
||||
// чтобы в прод-стили не попало ничего, связанного с удалением.
|
||||
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
|
||||
disabled={del.isPending}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
|
||||
@@ -103,6 +103,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
|
||||
.error-text { color: var(--danger); font-size: 13px; }
|
||||
|
||||
/* Изменение рейтинга за партию в истории профиля */
|
||||
.rating-delta { color: var(--text-muted); margin-top: 6px; }
|
||||
.rating-up { color: var(--success); }
|
||||
.rating-down { color: var(--danger); }
|
||||
|
||||
/* Формула в справке */
|
||||
.formula {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
@@ -371,6 +376,30 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
/* Строка под игроком: комментарий + цели и миры на конец партии */
|
||||
.rank-details { display: flex; gap: 6px; align-items: flex-end; }
|
||||
.rank-details .rank-comment { flex: 1; min-width: 0; }
|
||||
.rank-count { flex: 0 0 58px; margin-top: 8px; }
|
||||
.rank-count span { display: block; font-size: 10px; color: var(--text-muted); text-align: center; }
|
||||
.rank-count input {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
padding: 7px 4px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Предупреждения о несогласованном вводе (не блокируют отправку) */
|
||||
.warn-list {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px 8px 26px;
|
||||
border: 1px solid var(--accent-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(240, 160, 75, 0.08);
|
||||
color: var(--accent-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.warn-list li + li { margin-top: 4px; }
|
||||
.rank-unlink {
|
||||
flex: none;
|
||||
width: 30px; height: 30px;
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
/* Таблица общей статистики (сортируемая, кликабельные строки) */
|
||||
.st-row, .st-head {
|
||||
display: grid;
|
||||
grid-template-columns: 26px 1fr 40px 40px 48px 52px;
|
||||
grid-template-columns: 26px 1fr 40px 48px 60px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 2px;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
[33mcommit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mdev[m[33m)[m
|
||||
Author: NotBigGhost <ivan@arseniev.info>
|
||||
Date: Wed Jun 17 04:45:33 2026 +0300
|
||||
|
||||
Добавление ssh-ключа к контейнеру tunnel
|
||||
|
||||
[33mcommit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366[m
|
||||
Author: NotBigGhost <ivan@arseniev.info>
|
||||
Date: Wed Jun 17 04:27:11 2026 +0300
|
||||
|
||||
Правки локального размещения дева, перенос тунеля в контейнер
|
||||
|
||||
[33mcommit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90[m
|
||||
Author: NotBigGhost <ivan@arseniev.info>
|
||||
Date: Tue Jun 16 19:15:54 2026 +0300
|
||||
|
||||
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
|
||||
|
||||
[33mcommit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f[m[33m ([m[1;31morigin/main[m[33m, [m[1;32mmain[m[33m)[m
|
||||
Author: NotBigGhost <ivan@arseniev.info>
|
||||
Date: Tue Jun 16 17:41:06 2026 +0300
|
||||
|
||||
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
|
||||
|
||||
[33mcommit 6ab74f01aaf1ac042a269c7660b3e564b93350c7[m
|
||||
Author: NotBigGhost <ivan@arseniev.info>
|
||||
Date: Tue Jun 16 16:53:41 2026 +0300
|
||||
|
||||
first commit
|
||||
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Unified launcher for dev / test. APP_ENV in the root .env decides what to run.
|
||||
# Unified launcher for dev. APP_ENV in the root .env decides what to run.
|
||||
# development -> uvicorn --reload (backend) + vite (frontend), native, two windows
|
||||
# test -> docker compose prod-clone (port 8080)
|
||||
# production -> NOT started by launcher (prod is separate: docker compose up -d on Pi)
|
||||
#
|
||||
# LOCAL_PUBLIC=vps (dev only) opens an SSH tunnel to the VPS, exposing dev at
|
||||
# https://forbidden-stars.ru. test/prod expose themselves via an in-container tunnel.
|
||||
# LOCAL_PUBLIC=vps opens an SSH tunnel to the VPS, exposing dev at
|
||||
# https://forbidden-stars.ru. Prod exposes itself via an in-container tunnel.
|
||||
#
|
||||
# Run: .\run.ps1
|
||||
# If blocked by policy: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||
@@ -129,42 +128,18 @@ switch ($appEnv) {
|
||||
if ($localPublic -eq "vps") {
|
||||
Start-VpsTunnel 5173
|
||||
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
||||
Write-Host " WARNING: dev is public. Anyone can log in by nickname without a password," -ForegroundColor Yellow
|
||||
Write-Host " list/create players, hard-delete accounts and read Swagger." -ForegroundColor Yellow
|
||||
Write-Host " Do not keep a copy of production data in the dev database." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
"test" {
|
||||
$compose = Join-Path $root "docker-compose.test.yml"
|
||||
docker info --format '{{.ServerVersion}}' *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Docker daemon is not reachable. Start Docker Desktop, wait until it is running, then re-run .\run.ps1" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$keyFile = Join-Path $root "deploy\tunnel\id_tunnel"
|
||||
if (-not (Test-Path $keyFile -PathType Leaf)) {
|
||||
if (Test-Path $keyFile -PathType Container) {
|
||||
Write-Host "deploy\tunnel\id_tunnel is a DIRECTORY - Docker auto-created it because the key file was missing." -ForegroundColor Red
|
||||
Write-Host "Remove it first: Remove-Item -Recurse -Force deploy\tunnel\id_tunnel" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "No SSH key at deploy\tunnel\id_tunnel (the tunnel container needs it)." -ForegroundColor Red
|
||||
}
|
||||
Write-Host "Add your VPS-authorized key: Copy-Item `$env:USERPROFILE\.ssh\id_ed25519 deploy\tunnel\id_tunnel" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Building and starting prod-clone in Docker (app + in-container tunnel)..." -ForegroundColor Green
|
||||
docker compose -f $compose up --build -d
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Write-Host ""
|
||||
Write-Host " Public: https://forbidden-stars.ru (Swagger: /api/docs)" -ForegroundColor Green
|
||||
Write-Host " (no host port - reachable only via the domain; tunnel runs inside compose)"
|
||||
Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f"
|
||||
Write-Host " Stop: docker compose -f docker-compose.test.yml down -v"
|
||||
}
|
||||
"production" {
|
||||
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
||||
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build"
|
||||
Write-Host "Deploy: PC (main branch) scripts\build-push.ps1, then on Pi: docker compose up -d"
|
||||
exit 1
|
||||
}
|
||||
default {
|
||||
Write-Host "Unknown APP_ENV='$appEnv'. Allowed: development | test | production." -ForegroundColor Red
|
||||
Write-Host "Unknown APP_ENV='$appEnv'. Allowed: development | production." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ Единый лаунчер dev / test. Что запускать — решает APP_ENV из корневого .env ║
|
||||
# ║ Единый лаунчер dev. Что запускать — решает APP_ENV из корневого .env ║
|
||||
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
# development → uvicorn --reload (бэк) + vite (фронт), нативно (Ctrl+C останавливает оба)
|
||||
# test → docker compose прод-клон (порт 8080)
|
||||
# production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi)
|
||||
#
|
||||
# LOCAL_PUBLIC=vps (только dev) поднимает SSH-туннель на VPS → https://forbidden-stars.ru
|
||||
# test/prod выставляют себя сами через туннель-контейнер (см. docker-compose*.yml).
|
||||
# LOCAL_PUBLIC=vps поднимает SSH-туннель на VPS → https://forbidden-stars.ru
|
||||
# Прод выставляет себя сам через туннель-контейнер (см. docker-compose.yml).
|
||||
#
|
||||
# Запуск: ./run.sh (при необходимости: chmod +x run.sh)
|
||||
set -euo pipefail
|
||||
@@ -76,25 +75,21 @@ case "$app_env" in
|
||||
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
||||
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
||||
echo " Фронт: http://127.0.0.1:5173"
|
||||
[ "$local_public" = "vps" ] && start_tunnel 5173
|
||||
if [ "$local_public" = "vps" ]; then
|
||||
start_tunnel 5173
|
||||
echo " ВНИМАНИЕ: dev опубликован. Любой посетитель может войти по нику без пароля,"
|
||||
echo " смотреть и создавать игроков, жёстко удалять аккаунты и читать Swagger."
|
||||
echo " Не держите в dev-базе копию прод-данных."
|
||||
fi
|
||||
( cd "$root/frontend" && npm run dev )
|
||||
;;
|
||||
test)
|
||||
docker info >/dev/null 2>&1 || { echo "Docker-демон недоступен — запусти Docker и повтори ./run.sh"; exit 1; }
|
||||
echo "Сборка и запуск прод-клона в Docker (app + туннель в контейнере)…"
|
||||
docker compose -f "$root/docker-compose.test.yml" up --build -d
|
||||
echo " Публично: https://forbidden-stars.ru (Swagger: /api/docs)"
|
||||
echo " (портов на хост нет — только через домен; туннель живёт внутри compose)"
|
||||
echo " Логи: docker compose -f docker-compose.test.yml logs -f"
|
||||
echo " Стоп: docker compose -f docker-compose.test.yml down -v"
|
||||
;;
|
||||
production)
|
||||
echo "production лаунчером не запускается — прод обособлен."
|
||||
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
|
||||
echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | test | production."
|
||||
echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | production."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
|
||||
# прод-контейнера (без тестов, dev-входа и тест-специфики).
|
||||
#
|
||||
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
|
||||
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
|
||||
set -euo pipefail
|
||||
|
||||
DEST="${1:?Укажите целевую папку: scripts/export-prod.sh <dir> [ref]}"
|
||||
REF="${2:-HEAD}"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
|
||||
git archive --format=tar "$REF" | tar -x -C "$DEST"
|
||||
|
||||
# тест/dev-специфика в проде не нужна (тест-compose и лаунчер)
|
||||
rm -f "$DEST/docker-compose.test.yml" "$DEST/run.ps1" "$DEST/run.sh"
|
||||
|
||||
echo "[export-prod] Прод выгружен в: $DEST"
|
||||
echo " дальше: cp .env.example .env && docker compose up -d --build"
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Выгрузка ТЕСТА (прод-клон для локального контейнера на x86): в целевую папку
|
||||
# попадают только файлы, нужные для запуска тест-контейнера.
|
||||
#
|
||||
# Использование: scripts/export-test.sh <целевая-папка> [git-ref]
|
||||
set -euo pipefail
|
||||
|
||||
DEST="${1:?Укажите целевую папку: scripts/export-test.sh <dir> [ref]}"
|
||||
REF="${2:-HEAD}"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
git archive --format=tar "$REF" | tar -x -C "$DEST"
|
||||
|
||||
# прод-compose в тест-папке не нужен (тест запускается своим docker-compose.test.yml)
|
||||
rm -f "$DEST/docker-compose.yml"
|
||||
|
||||
echo "[export-test] Тест выгружен в: $DEST"
|
||||
echo " дальше: cp .env.example .env (APP_ENV=test) && docker compose -f docker-compose.test.yml up -d --build"
|
||||
+17
-88
@@ -1,6 +1,5 @@
|
||||
# Forbidden Stars backups from the PC. Talks to the `backup` container of the prod on the Pi
|
||||
# over SSH, or (with -Target test) to the local test clone (docker-compose.test.yml).
|
||||
# Step-by-step guide: deploy/backup/README.md
|
||||
# over SSH. Step-by-step guide: deploy/backup/README.md
|
||||
#
|
||||
# .\scripts\fs-backup.ps1 status backup state on the Pi
|
||||
# .\scripts\fs-backup.ps1 list [-Repo vps] snapshot history
|
||||
@@ -8,9 +7,6 @@
|
||||
# .\scripts\fs-backup.ps1 verify check data integrity in the repositories
|
||||
# .\scripts\fs-backup.ps1 pull [-Snapshot <id>] [-Repo vps]
|
||||
# download a snapshot to backups\ (sha256 checked)
|
||||
# .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar
|
||||
# practice restore into the local test clone
|
||||
# add -Target test to run status/list/now/verify/pull against the local test clone
|
||||
#
|
||||
# Settings come from the root .env (an environment variable with the same name wins):
|
||||
# BACKUP_PI_SSH how to reach the Pi over SSH, e.g. pi@192.168.1.10 (or a Host alias)
|
||||
@@ -19,15 +15,12 @@
|
||||
# Keep this file ASCII-only: Windows PowerShell 5.1 breaks on non-ASCII without a BOM.
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet("status", "list", "now", "verify", "pull", "restore-test", "help")]
|
||||
[ValidateSet("status", "list", "now", "verify", "pull", "help")]
|
||||
[string]$Command = "help",
|
||||
[string]$Snapshot = "latest",
|
||||
[ValidateSet("local", "vps")]
|
||||
[string]$Repo = "local",
|
||||
[string]$Tag = "",
|
||||
[string]$File = "",
|
||||
[ValidateSet("pi", "test")]
|
||||
[string]$Target = "pi"
|
||||
[string]$Tag = ""
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
# Native tools (ssh, scp, docker) write progress and warnings to stderr. Under "Stop" with a
|
||||
@@ -35,7 +28,6 @@ $ErrorActionPreference = "Stop"
|
||||
# run under "Continue" and success is judged by $LASTEXITCODE only.
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$envFile = Join-Path $root ".env"
|
||||
$testCompose = Join-Path $root "docker-compose.test.yml"
|
||||
|
||||
# Read a key: environment variable first, then the root .env (last assignment wins).
|
||||
function Get-Setting([string]$name, [string]$default) {
|
||||
@@ -56,7 +48,7 @@ function Fail([string]$msg) {
|
||||
}
|
||||
|
||||
function Show-Help {
|
||||
Get-Content $PSCommandPath -TotalCount 19 | ForEach-Object { $_ -replace '^# ?', '' }
|
||||
Get-Content $PSCommandPath -TotalCount 15 | ForEach-Object { $_ -replace '^# ?', '' }
|
||||
}
|
||||
|
||||
$piSsh = Get-Setting "BACKUP_PI_SSH" ""
|
||||
@@ -77,29 +69,9 @@ function Invoke-Scp([string]$from, [string]$to) {
|
||||
& scp -o ConnectTimeout=15 $from $to
|
||||
}
|
||||
|
||||
function Invoke-TestCompose([string[]]$composeArgs) {
|
||||
$ErrorActionPreference = "Continue"
|
||||
& docker compose -f $testCompose @composeArgs
|
||||
}
|
||||
|
||||
# Make sure the backup container of the test clone is running (build it if needed).
|
||||
function Start-TestBackup {
|
||||
$id = (Invoke-TestCompose @("ps", "-q", "backup")) | Select-Object -First 1
|
||||
if (-not $id) {
|
||||
Write-Host "Starting the backup container of the test clone..." -ForegroundColor Cyan
|
||||
Invoke-TestCompose @("up", "-d", "--build", "backup") | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { Fail "Could not start the test clone backup container." }
|
||||
}
|
||||
}
|
||||
|
||||
# Run fs-backup with arguments on the chosen target; output goes to the console.
|
||||
# Run fs-backup with arguments on the Pi; output goes to the console.
|
||||
function Invoke-FsBackup([string[]]$fsArgs) {
|
||||
if ($Target -eq "test") {
|
||||
Start-TestBackup
|
||||
Invoke-TestCompose (@("exec", "-T", "backup", "fs-backup") + $fsArgs)
|
||||
} else {
|
||||
Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " "))
|
||||
}
|
||||
Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " "))
|
||||
}
|
||||
|
||||
function Assert-LastExit([string]$what) {
|
||||
@@ -126,31 +98,17 @@ function Invoke-Pull {
|
||||
Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan
|
||||
|
||||
$partial = "$local.part"
|
||||
if ($Target -eq "test") {
|
||||
Start-TestBackup
|
||||
$tmp = "/tmp/fs-backup/export-$id.tar"
|
||||
$hashLine = Invoke-TestCompose @("exec", "-T", "backup", "sh", "-c",
|
||||
"fs-backup export $id --repo $Repo > $tmp && sha256sum $tmp") | Select-Object -Last 1
|
||||
Assert-LastExit "Export"
|
||||
try {
|
||||
Invoke-TestCompose @("cp", "backup:$tmp", $partial)
|
||||
Assert-LastExit "Copy from the container"
|
||||
} finally {
|
||||
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $tmp) | Out-Null
|
||||
}
|
||||
} else {
|
||||
# Export into a file in the Pi user's home (binary data never passes through
|
||||
# PowerShell pipes - they would corrupt it), then scp it and compare sha256.
|
||||
$remote = "fs-export-$id.tar"
|
||||
$hashLine = Invoke-Pi "docker compose exec -T backup fs-backup export $id --repo $Repo > ~/$remote && sha256sum ~/$remote" |
|
||||
Select-Object -Last 1
|
||||
Assert-LastExit "Export on the Pi"
|
||||
try {
|
||||
Invoke-Scp "${piSsh}:$remote" $partial
|
||||
Assert-LastExit "scp"
|
||||
} finally {
|
||||
Invoke-Pi "rm -f ~/$remote"
|
||||
}
|
||||
# Export into a file in the Pi user's home (binary data never passes through
|
||||
# PowerShell pipes - they would corrupt it), then scp it and compare sha256.
|
||||
$remote = "fs-export-$id.tar"
|
||||
$hashLine = Invoke-Pi "docker compose exec -T backup fs-backup export $id --repo $Repo > ~/$remote && sha256sum ~/$remote" |
|
||||
Select-Object -Last 1
|
||||
Assert-LastExit "Export on the Pi"
|
||||
try {
|
||||
Invoke-Scp "${piSsh}:$remote" $partial
|
||||
Assert-LastExit "scp"
|
||||
} finally {
|
||||
Invoke-Pi "rm -f ~/$remote"
|
||||
}
|
||||
|
||||
$expected = ("$hashLine".Trim() -split "\s+")[0].ToLower()
|
||||
@@ -172,34 +130,6 @@ function Invoke-Pull {
|
||||
Write-Host "OK: $local ($sizeMb MB, $files files, sha256 verified)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------- restore-test
|
||||
function Invoke-RestoreTest {
|
||||
if (-not $File) { Fail "Specify the archive: -File backups\fs_....tar" }
|
||||
if (-not (Test-Path $File -PathType Leaf)) { Fail "File not found: $File" }
|
||||
$full = (Resolve-Path $File).Path
|
||||
$inContainer = "/import/restore-test.archive" # tar or tar.gz: fs-backup detects the format itself
|
||||
|
||||
Write-Host "Practice restore of $full into the LOCAL TEST CLONE (prod is not touched)." -ForegroundColor Cyan
|
||||
Start-TestBackup
|
||||
Invoke-TestCompose @("stop", "app")
|
||||
Assert-LastExit "Stopping the test app"
|
||||
Invoke-TestCompose @("cp", $full, "backup:$inContainer")
|
||||
Assert-LastExit "Copy into the container"
|
||||
try {
|
||||
Invoke-TestCompose @("exec", "-T", "backup", "fs-backup", "import", $inContainer, "--yes")
|
||||
$importExit = $LASTEXITCODE
|
||||
} finally {
|
||||
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $inContainer) | Out-Null
|
||||
}
|
||||
if ($importExit -ne 0) { Fail "Import failed (exit code $importExit). The test clone data was not changed." }
|
||||
|
||||
Invoke-TestCompose @("up", "-d", "app")
|
||||
Assert-LastExit "Starting the test app"
|
||||
Write-Host "Done. The test clone now runs on the restored data." -ForegroundColor Green
|
||||
Write-Host " See it on https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (or .\run.ps1 with APP_ENV=test)"
|
||||
Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f app"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- main
|
||||
$prevEncoding = $null
|
||||
try {
|
||||
@@ -218,7 +148,6 @@ try {
|
||||
}
|
||||
"verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" }
|
||||
"pull" { Invoke-Pull }
|
||||
"restore-test" { Invoke-RestoreTest }
|
||||
default { Show-Help }
|
||||
}
|
||||
} finally {
|
||||
|
||||
+9
-58
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup
|
||||
# прода на Pi по SSH или (с --test) в локальный тест-клон (docker-compose.test.yml).
|
||||
# На Windows удобнее scripts/fs-backup.ps1 — поведение то же. Инструкция: deploy/backup/README.md
|
||||
# прода на Pi по SSH. На Windows удобнее scripts/fs-backup.ps1 — поведение то же.
|
||||
# Инструкция: deploy/backup/README.md
|
||||
#
|
||||
# scripts/fs-backup.sh status состояние бэкапов на Pi
|
||||
# scripts/fs-backup.sh list [vps] хронология снимков
|
||||
@@ -9,9 +9,6 @@
|
||||
# scripts/fs-backup.sh verify проверить целостность данных
|
||||
# scripts/fs-backup.sh pull [<id>|latest] [--repo vps]
|
||||
# скачать снимок в backups/ (сверка sha256)
|
||||
# scripts/fs-backup.sh restore-test <файл.tar|.tar.gz>
|
||||
# учебное восстановление в локальный тест-клон
|
||||
# --test первым аргументом — status/list/now/verify/pull для локального тест-клона
|
||||
#
|
||||
# Настройки — из корневого .env (переменная окружения с тем же именем важнее):
|
||||
# BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config)
|
||||
@@ -19,8 +16,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TEST_COMPOSE="$PROJECT_DIR/docker-compose.test.yml"
|
||||
export MSYS_NO_PATHCONV=1 # Git Bash: не переписывать /import/... в аргументах docker
|
||||
|
||||
die() { echo "ОШИБКА: $*" >&2; exit 1; }
|
||||
|
||||
@@ -35,10 +30,7 @@ setting() { # setting <ключ> <по умолчанию>: окружение,
|
||||
PI_SSH="$(setting BACKUP_PI_SSH "")"
|
||||
# shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi
|
||||
PI_DIR="$(setting BACKUP_PI_DIR "~/forbidden-stars")"
|
||||
TARGET="pi"
|
||||
if [ "${1:-}" = "--test" ]; then TARGET="test"; shift; fi
|
||||
|
||||
native_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi; }
|
||||
sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; }
|
||||
|
||||
pi() { # pi <shell-команда>: выполнить на Pi в папке прода
|
||||
@@ -46,22 +38,8 @@ pi() { # pi <shell-команда>: выполнить на Pi в папке п
|
||||
ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1"
|
||||
}
|
||||
|
||||
tc() { docker compose -f "$(native_path "$TEST_COMPOSE")" "$@"; }
|
||||
|
||||
start_test_backup() {
|
||||
if [ -z "$(tc ps -q backup 2>/dev/null)" ]; then
|
||||
echo "Запускаю контейнер backup тест-клона…"
|
||||
tc up -d --build backup
|
||||
fi
|
||||
}
|
||||
|
||||
fs() { # fs <аргументы fs-backup…>
|
||||
if [ "$TARGET" = test ]; then
|
||||
start_test_backup
|
||||
tc exec -T backup fs-backup "$@"
|
||||
else
|
||||
pi "docker compose exec -T backup fs-backup $*"
|
||||
fi
|
||||
fs() { # fs <аргументы fs-backup…>: выполнить fs-backup в контейнере backup на Pi
|
||||
pi "docker compose exec -T backup fs-backup $*"
|
||||
}
|
||||
|
||||
cmd_pull() {
|
||||
@@ -84,18 +62,10 @@ cmd_pull() {
|
||||
partial="$local_file.part"
|
||||
echo "Снимок $id ($repo) -> $local_file"
|
||||
|
||||
if [ "$TARGET" = test ]; then
|
||||
start_test_backup
|
||||
local tmp="/tmp/fs-backup/export-$id.tar"
|
||||
expected="$(tc exec -T backup sh -c "fs-backup export $id --repo $repo > $tmp && sha256sum $tmp" | tail -n 1 | cut -d' ' -f1)"
|
||||
tc cp "backup:$tmp" "$(native_path "$partial")" || { tc exec -T backup rm -f "$tmp"; die "копирование из контейнера не удалось"; }
|
||||
tc exec -T backup rm -f "$tmp"
|
||||
else
|
||||
local remote="fs-export-$id.tar"
|
||||
expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)"
|
||||
scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; }
|
||||
pi "rm -f ~/$remote"
|
||||
fi
|
||||
local remote="fs-export-$id.tar"
|
||||
expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)"
|
||||
scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; }
|
||||
pi "rm -f ~/$remote"
|
||||
|
||||
actual="$(sha256 "$partial")"
|
||||
if [ "$expected" != "$actual" ]; then
|
||||
@@ -107,24 +77,6 @@ cmd_pull() {
|
||||
echo "OK: $local_file ($(du -h "$local_file" | cut -f1), $(tar -tf "$local_file" | grep -vc '/$') файлов, sha256 сверена)"
|
||||
}
|
||||
|
||||
cmd_restore_test() {
|
||||
local file="${1:-}"
|
||||
[ -n "$file" ] || die "укажите архив: scripts/fs-backup.sh restore-test backups/fs_....tar"
|
||||
[ -f "$file" ] || die "файл не найден: $file"
|
||||
local in_container=/import/restore-test.archive # tar или tar.gz — формат fs-backup определит сам
|
||||
echo "Учебное восстановление $file в ЛОКАЛЬНЫЙ ТЕСТ-КЛОН (прод не затрагивается)."
|
||||
start_test_backup
|
||||
tc stop app
|
||||
tc cp "$(native_path "$file")" "backup:$in_container"
|
||||
local rc=0
|
||||
tc exec -T backup fs-backup import "$in_container" --yes || rc=$?
|
||||
tc exec -T backup rm -f "$in_container"
|
||||
[ "$rc" -eq 0 ] || die "импорт не удался (код $rc) — данные тест-клона не изменены."
|
||||
tc up -d app
|
||||
echo "Готово: тест-клон работает на восстановленных данных."
|
||||
echo " На https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (или ./run.sh при APP_ENV=test)"
|
||||
}
|
||||
|
||||
cmd="${1:-help}"
|
||||
[ $# -eq 0 ] || shift
|
||||
case "$cmd" in
|
||||
@@ -133,6 +85,5 @@ case "$cmd" in
|
||||
now) fs run "$@" ;;
|
||||
verify) fs verify ;;
|
||||
pull) cmd_pull "$@" ;;
|
||||
restore-test) cmd_restore_test "$@" ;;
|
||||
*) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user