Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3984638c3 | ||
|
|
96c32d99c5 | ||
|
|
30e9669361 | ||
|
|
867b1f868c | ||
|
|
74b9b6698f | ||
|
|
05e1f79c25 | ||
|
|
98cd82f045 | ||
|
|
e44a62f9cc | ||
|
|
9f599f18d7 | ||
|
|
90d0eb2546 | ||
|
|
64ac0efb36 | ||
|
|
5fad9dbea4 |
+5
-14
@@ -1,23 +1,13 @@
|
|||||||
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
|
|
||||||
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
|
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
**/*.egg-info/
|
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
backend/.pytest_cache/
|
backend/.pytest_cache/
|
||||||
|
backend/*.db
|
||||||
|
backend/*.db-wal
|
||||||
|
backend/*.db-shm
|
||||||
backend/openapi.json
|
backend/openapi.json
|
||||||
|
|
||||||
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
|
|
||||||
# локальные копии бэкапов — ни в образ, ни в контекст сборки
|
|
||||||
backend/data/
|
|
||||||
data/
|
|
||||||
backups/
|
|
||||||
**/*.db
|
|
||||||
**/*.db-wal
|
|
||||||
**/*.db-shm
|
|
||||||
|
|
||||||
# Node / сборка фронта
|
# Node / сборка фронта
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
@@ -33,7 +23,8 @@ backend/tests/
|
|||||||
backend/pyproject.toml
|
backend/pyproject.toml
|
||||||
|
|
||||||
# Прочее
|
# Прочее
|
||||||
**/.env
|
.env
|
||||||
|
data/
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
*.md
|
*.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
+22
-49
@@ -1,5 +1,5 @@
|
|||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Forbidden Stars — единый .env (dev / prod)
|
# Forbidden Stars — единый .env (dev / test / prod)
|
||||||
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
|
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
|
||||||
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
|
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
||||||
|
# test — прод-клон в Docker локально (порт 8080), вход только TG
|
||||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
@@ -14,24 +15,21 @@ APP_ENV=development
|
|||||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||||
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
|
# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им
|
||||||
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
|
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают.
|
||||||
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
|
|
||||||
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
|
||||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
|
||||||
LOCAL_PUBLIC=local
|
LOCAL_PUBLIC=local
|
||||||
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода.
|
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod.
|
||||||
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
|
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
|
||||||
VPS_TUNNEL_HOST=186.246.51.17
|
VPS_TUNNEL_HOST=186.246.51.17
|
||||||
VPS_TUNNEL_USER=tunnel
|
VPS_TUNNEL_USER=tunnel
|
||||||
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
|
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
|
||||||
# dev (run.ps1) → 9001, прод-контейнер → 9000.
|
# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001.
|
||||||
# Раскомментируй и переопредели, только если нужен нестандартный слот.
|
# Раскомментируй и переопредели, только если нужен нестандартный слот.
|
||||||
#VPS_TUNNEL_PORT=9001
|
#VPS_TUNNEL_PORT=9001
|
||||||
|
|
||||||
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
|
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
|
||||||
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод
|
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test
|
||||||
# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
|
# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
|
||||||
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
|
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
|
||||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel")))
|
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel")))
|
||||||
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
|
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
|
||||||
@@ -47,28 +45,27 @@ ADMIN_BOOTSTRAP_ENABLED=true
|
|||||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
||||||
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||||
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
||||||
# и forbidden-stars.ru (dev).
|
# и forbidden-stars.ru (dev/test).
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_BOT_USERNAME=
|
TELEGRAM_BOT_USERNAME=
|
||||||
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru.
|
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru.
|
||||||
PUBLIC_BASE_URL=
|
PUBLIC_BASE_URL=
|
||||||
|
|
||||||
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
|
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
|
||||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||||
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
|
|
||||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_USER_TTL_MINUTES=10080
|
JWT_USER_TTL_MINUTES=10080
|
||||||
JWT_ADMIN_TTL_MINUTES=480
|
JWT_ADMIN_TTL_MINUTES=480
|
||||||
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
|
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
|
||||||
# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно.
|
# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно.
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
|
|
||||||
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
|
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
|
||||||
# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL
|
# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL
|
||||||
# (том /data контейнера, см. docker-compose*.yml).
|
# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml).
|
||||||
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
|
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
|
||||||
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
||||||
|
|
||||||
@@ -93,40 +90,16 @@ IMAGE_TAG=latest
|
|||||||
#APP_MEM_LIMIT=512m
|
#APP_MEM_LIMIT=512m
|
||||||
#APP_CPUS=1.5
|
#APP_CPUS=1.5
|
||||||
|
|
||||||
# ─── БЭКАПЫ (контейнер backup: restic; подробно — deploy/backup/README.md) ────
|
# ─── БЭКАП (оффсайт-копия на VPS; читает scripts/backup.sh на хосте Pi) ───────
|
||||||
# Снимки БД + uploads + achievements по расписанию: локально на Pi (том backup-data) и —
|
# Полный бэкап (БД + uploads + achievements) кладётся локально в backups/, и — если задан
|
||||||
# если задан BACKUP_VPS_HOST — на VPS по SFTP. Шифрование, дедупликация, сжатие — restic.
|
# BACKUP_VPS_HOST — копируется на VPS (Pi → VPS push, т.к. Pi за CGNAT). Пусто = только локально.
|
||||||
#
|
# Ключ бэкапа — deploy/backup/id_backup (в git НЕ идёт); его pubkey → authorized_keys у backup@VPS.
|
||||||
# Пароль шифрования репозиториев. ПОТЕРЯЕТЕ ПАРОЛЬ — НИ ОДИН БЭКАП НЕ ПРОЧИТАТЬ.
|
|
||||||
# Сохраните его в менеджер паролей. Пусто = бэкапы отключены. Без символа '$'.
|
|
||||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(32))"
|
|
||||||
BACKUP_PASSWORD=
|
|
||||||
# Когда делать бэкап и проверку данных (cron: минута час день месяц день_недели; TZ ниже).
|
|
||||||
BACKUP_SCHEDULE="0 4 * * *"
|
|
||||||
BACKUP_VERIFY_SCHEDULE="30 5 * * 0"
|
|
||||||
BACKUP_TZ=Europe/Moscow
|
|
||||||
# Сколько хранить: последний снимок каждого дня / недели / месяца.
|
|
||||||
# Именованные снимки (fs-backup run --tag …) и страховочные pre-restore не удаляются.
|
|
||||||
BACKUP_KEEP_DAILY=14
|
|
||||||
BACKUP_KEEP_WEEKLY=8
|
|
||||||
BACKUP_KEEP_MONTHLY=12
|
|
||||||
# Сжатие restic: auto (быстрее) | max (плотнее) | off.
|
|
||||||
BACKUP_COMPRESSION=max
|
|
||||||
# Контейнер помечается unhealthy, если последний успешный бэкап старше стольких часов.
|
|
||||||
BACKUP_MAX_AGE_HOURS=30
|
|
||||||
# Оффсайт-копия на VPS (SFTP). Пусто = только локальная копия на Pi.
|
|
||||||
BACKUP_VPS_HOST=
|
BACKUP_VPS_HOST=
|
||||||
BACKUP_VPS_USER=fsbackup
|
BACKUP_VPS_USER=backup
|
||||||
BACKUP_VPS_PORT=22
|
BACKUP_VPS_DIR=/srv/fs-backups
|
||||||
BACKUP_VPS_DIR=/srv/fs-backups/restic
|
BACKUP_VPS_KEY=deploy/backup/id_backup
|
||||||
# Приватный SSH-ключ для VPS в base64 одной строкой (как TUNNEL_KEY_B64). На ПК ключ лежит
|
BACKUP_KEEP_LOCAL=14
|
||||||
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
BACKUP_KEEP_REMOTE=30
|
||||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
|
||||||
BACKUP_SSH_KEY_B64=
|
|
||||||
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
|
||||||
# docker-compose.yml прода.
|
|
||||||
BACKUP_PI_SSH=pi@192.168.1.10
|
|
||||||
BACKUP_PI_DIR=~/forbidden-stars
|
|
||||||
|
|
||||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
||||||
|
|||||||
+5
-3
@@ -1,12 +1,14 @@
|
|||||||
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
||||||
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
|
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
|
||||||
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
# entrypoint.sh контейнер чинит сам (sed в Dockerfile), но backup.sh запускается
|
||||||
|
# с хоста Pi — для него LF в репозитории критичен.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
backend/entrypoint.sh text eol=lf
|
backend/entrypoint.sh text eol=lf
|
||||||
|
scripts/backup.sh text eol=lf
|
||||||
|
|
||||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ─────────
|
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ─────
|
||||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
||||||
# но в архив деплоя (scripts/export-prod.sh) не идут. На Docker-сборку НЕ влияет —
|
# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет —
|
||||||
# там чистоту образа обеспечивает .dockerignore.
|
# там чистоту образа обеспечивает .dockerignore.
|
||||||
backend/tests/ export-ignore
|
backend/tests/ export-ignore
|
||||||
backend/app/auth/dev_stub.py export-ignore
|
backend/app/auth/dev_stub.py export-ignore
|
||||||
|
|||||||
+2
-3
@@ -15,7 +15,7 @@ venv/
|
|||||||
data/
|
data/
|
||||||
backend/dev.db*
|
backend/dev.db*
|
||||||
|
|
||||||
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod)
|
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod)
|
||||||
.env
|
.env
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ frontend/.vite/
|
|||||||
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
|
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
|
||||||
backend/openapi.json
|
backend/openapi.json
|
||||||
|
|
||||||
# Бэкапы, скачанные на ПК (scripts/fs-backup.* pull) — не зашифрованы, в git не идут
|
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
||||||
@@ -43,7 +43,6 @@ mockups/
|
|||||||
|
|
||||||
# AI-ассистенты (локальные, в репозиторий не идут)
|
# AI-ассистенты (локальные, в репозиторий не идут)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Редактор / ОС
|
# Редактор / ОС
|
||||||
|
|||||||
@@ -1,37 +1,29 @@
|
|||||||
# Forbidden Stars — учёт партий
|
# Forbidden Stars — учёт партий
|
||||||
|
|
||||||
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
||||||
профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями,
|
профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий
|
||||||
создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий,
|
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
||||||
уведомления, статистика и общий топ, админ-панель.
|
|
||||||
|
|
||||||
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
||||||
- **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые
|
- **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API)
|
||||||
обновления приходят SSE-потоком `/api/events`)
|
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker
|
||||||
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация»)
|
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
backend/ FastAPI: ядро, REST API, БД, миграции, seed
|
||||||
frontend/ React + Vite SPA
|
frontend/ React + Vite SPA
|
||||||
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh
|
docker-compose.yml
|
||||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
.env.example
|
||||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
|
||||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
|
||||||
run.ps1 / run.sh единый лаунчер dev
|
|
||||||
.env.example шаблон единого .env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
|
|
||||||
Всё проверяется в `development` — отдельного тестового контейнера нет.
|
|
||||||
|
|
||||||
### Единый лаунчер (`run.ps1` / `run.sh`)
|
### Единый лаунчер (`run.ps1` / `run.sh`)
|
||||||
|
|
||||||
После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает
|
После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно,
|
||||||
`APP_ENV` в корневом `.env`):
|
решает `APP_ENV` в корневом `.env`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
|
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
|
||||||
@@ -39,9 +31,9 @@ run.ps1 / run.sh единый лаунчер dev
|
|||||||
|
|
||||||
| `APP_ENV` в `.env` | что делает лаунчер |
|
| `APP_ENV` в `.env` | что делает лаунчер |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` |
|
| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах |
|
||||||
| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») |
|
| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) |
|
||||||
| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) |
|
| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») |
|
||||||
|
|
||||||
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
|
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
|
||||||
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
|
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
|
||||||
@@ -55,8 +47,8 @@ python -m venv .venv
|
|||||||
.\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
.\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||||
pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости)
|
pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости)
|
||||||
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
|
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
|
||||||
alembic upgrade head # применит миграции и сидинг справочников
|
alembic upgrade head # применит миграции и сидинг
|
||||||
python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально)
|
python -m app.bootstrap # создаст/синхронизирует администратора из .env
|
||||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
|
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -83,10 +75,6 @@ python -m app.bootstrap
|
|||||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
||||||
```
|
```
|
||||||
|
|
||||||
> В dev тот же bootstrap (справочники + админ из `.env`) выполняется сам при старте uvicorn,
|
|
||||||
> поэтому `python -m app.bootstrap` вручную обычно не нужен. Dev-БД, загрузки и ачивки
|
|
||||||
> лежат в `backend/data/dev/` (пути в `.env` относительные — запускайте из `backend/`).
|
|
||||||
|
|
||||||
> **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
|
> **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
|
||||||
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
|
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
|
||||||
> а в логе только `Reloading...`.
|
> а в логе только `Reloading...`.
|
||||||
@@ -103,121 +91,100 @@ npm run gen:api # сгенерирует типы из живог
|
|||||||
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
||||||
```
|
```
|
||||||
|
|
||||||
Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub);
|
Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику),
|
||||||
в проде stub нет (см. раздел «Аутентификация»).
|
в проде — только Telegram (см. раздел «Аутентификация»).
|
||||||
|
|
||||||
## Production (Docker на Pi)
|
## Production (Docker на Pi)
|
||||||
|
|
||||||
На Pi нужны только **два файла** — `docker-compose.yml` и `.env`: образы `app`, `tunnel` и
|
|
||||||
`backup` собираются на ПК под arm64 и пушатся в Gitea-реестр, Pi тянет их сам.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# ПК (обычно с ветки main): собрать и опубликовать образы
|
|
||||||
docker login gitea.arseniev.info
|
|
||||||
.\scripts\build-push.ps1
|
|
||||||
```
|
|
||||||
```bash
|
```bash
|
||||||
# Pi, папка с docker-compose.yml и .env
|
cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр.
|
||||||
docker compose up -d # pull_policy: always — тянет свежие образы, без сборки
|
docker compose build # на ARM64 собирается нативно
|
||||||
|
docker compose up -d # приложение на :8000, БД на томе
|
||||||
```
|
```
|
||||||
|
|
||||||
Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через
|
FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа
|
||||||
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
выполняются автоматически при старте (`entrypoint.sh`).
|
||||||
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
|
||||||
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
|
||||||
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
|
|
||||||
при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и
|
|
||||||
`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские
|
|
||||||
сессии завершатся; подробно — в `deploy/pi/README.md`).
|
|
||||||
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
|
||||||
|
|
||||||
|
## Test — локальный прод-клон в контейнере
|
||||||
|
|
||||||
|
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
|
||||||
|
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
||||||
|
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
||||||
|
|
||||||
|
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
||||||
|
Вручную (тот же эффект):
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.test.yml up -d --build
|
||||||
|
# открыть http://localhost:8080 (Swagger: /api/docs)
|
||||||
|
docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные
|
||||||
|
```
|
||||||
|
|
||||||
|
- Окружение `test` (прод-клон), но `COOKIE_SECURE=false` (локально по HTTP).
|
||||||
|
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
||||||
|
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
||||||
|
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
||||||
|
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** —
|
||||||
|
только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`).
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало
|
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||||
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
|
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
|
||||||
- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` —
|
|
||||||
поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`,
|
|
||||||
тот же слот VPS 9000, что у Pi (одновременно не запускать).
|
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
|
|
||||||
Методы входа зависят от окружения (`APP_ENV`):
|
Методы входа зависят от окружения (`APP_ENV`):
|
||||||
|
|
||||||
| | dev | prod |
|
| | dev | test / prod |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
| Telegram Login Widget | ✓ | ✓ (единственный) |
|
||||||
| Telegram Login Widget | ✓ | ✓ |
|
| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
|
||||||
| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) |
|
|
||||||
|
|
||||||
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
|
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
||||||
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
|
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
||||||
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и
|
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
||||||
50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше
|
(`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`).
|
||||||
10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа
|
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`).
|
||||||
видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или
|
`GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета.
|
||||||
выйти; в 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 (когда будете подключать реальный вход):**
|
**Настройка Telegram (когда будете подключать реальный вход):**
|
||||||
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
|
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
|
||||||
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev).
|
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test).
|
||||||
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
|
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
|
||||||
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
|
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
|
||||||
|
|
||||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех
|
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях.
|
||||||
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
|
|
||||||
|
|
||||||
## Окружения (dev / prod)
|
## Окружения (dev / test / prod)
|
||||||
|
|
||||||
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только
|
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер):
|
||||||
`development` и `production` — с любым другим значением бэкенд не стартует:
|
|
||||||
|
|
||||||
| | dev | prod (Pi) |
|
| | dev | test (прод-клон локально) | prod (Pi) |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` |
|
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` |
|
||||||
| `APP_ENV` | `development` | `production` (форсится в compose) |
|
| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) |
|
||||||
| Env-файл | единый `.env` | единый `.env` (на Pi) |
|
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
||||||
| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` |
|
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
||||||
| База данных | `backend/data/dev/…` | том `db-data` (`/data`) |
|
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
||||||
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram |
|
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
|
||||||
| Swagger (`/api/docs`) | ✓ | ✗ |
|
|
||||||
| Fail-fast по дефолтным секретам | ✗ | ✓ |
|
|
||||||
|
|
||||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение
|
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
||||||
`APP_ENV` из него **игнорирует** и всегда `production`.
|
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
||||||
|
`production`. Отдельного `.env.test` больше нет.
|
||||||
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
|
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
|
||||||
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
(`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это
|
||||||
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`).
|
||||||
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа
|
||||||
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
|
(`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`.
|
||||||
и локальные бэкапы (`backups/`) в образ тоже не попадают.
|
|
||||||
|
|
||||||
## Git и деплой
|
## Git и деплой
|
||||||
|
|
||||||
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь.
|
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь.
|
||||||
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
|
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
|
||||||
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
|
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
|
||||||
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
|
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
|
||||||
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
- **Деплой на Pi:** `git pull` ветки `main` → `scripts/build-push.ps1`.
|
||||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh <dir> main` (через
|
||||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
`git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа).
|
||||||
- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится):
|
|
||||||
`scripts/export-prod.sh <dir> [ref]` — через `git archive` + `export-ignore` из
|
|
||||||
`.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера).
|
|
||||||
`dev_admin.py` в `export-ignore` пока не внесён (задача #70).
|
|
||||||
|
|
||||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||||
|
|
||||||
@@ -225,32 +192,21 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
|
|
||||||
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
|
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
|
||||||
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
|
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
|
||||||
У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он
|
У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост
|
||||||
не публикует (доступен только через домен):
|
они не публикуют (доступны только через домен):
|
||||||
|
|
||||||
| | домен | как выставляется | слот VPS |
|
| | домен | как выставляется | слот VPS |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
|
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
|
||||||
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 |
|
||||||
|
| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||||
|
|
||||||
- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный
|
- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают
|
||||||
прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
|
одновременно. Dev и test делят слот 9001 → по очереди.
|
||||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
|
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен.
|
||||||
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
|
|
||||||
время проверки (задача #69).
|
|
||||||
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
|
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
|
||||||
- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi —
|
|
||||||
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
|
|
||||||
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
|
|
||||||
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
|
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
|
||||||
`pi/` (app + туннель + бэкапы в Docker), образ туннеля — `deploy/tunnel/`.
|
`pi/` (app + туннель в Docker), образ туннеля — `deploy/tunnel/`. Ключ — `deploy/tunnel/id_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).
|
|
||||||
|
|
||||||
## Дополнения и фракции
|
## Дополнения и фракции
|
||||||
|
|
||||||
@@ -261,6 +217,4 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
| Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
|
| Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
|
||||||
|
|
||||||
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
|
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
|
||||||
этих дополнений (база — всегда). Справочник сидится из `backend/app/seed/reference_data.py`.
|
этих дополнений (база — всегда).
|
||||||
Админ может переименовать фракцию в панели, но сейчас переименование откатывается при
|
|
||||||
каждом перезапуске приложения (задача #72).
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Пользователь: версия сессий (token_version) для отзыва JWT.
|
|
||||||
|
|
||||||
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
|
||||||
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
|
|
||||||
|
|
||||||
Инкремент token_version отзывает все ранее выданные токены пользователя (см. auth/deps, #57).
|
|
||||||
|
|
||||||
Revision ID: 0013_user_token_version
|
|
||||||
Revises: 0012_match_finish_draft
|
|
||||||
Create Date: 2026-09-13
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "0013_user_token_version"
|
|
||||||
down_revision: Union[str, None] = "0012_match_finish_draft"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
bind = op.get_bind()
|
|
||||||
insp = inspect(bind)
|
|
||||||
cols = {c["name"] for c in insp.get_columns("users")}
|
|
||||||
with op.batch_alter_table("users") as b:
|
|
||||||
if "token_version" not in cols:
|
|
||||||
b.add_column(
|
|
||||||
sa.Column(
|
|
||||||
"token_version", sa.Integer(), nullable=False, server_default="0"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
bind = op.get_bind()
|
|
||||||
insp = inspect(bind)
|
|
||||||
cols = {c["name"] for c in insp.get_columns("users")}
|
|
||||||
with op.batch_alter_table("users") as b:
|
|
||||||
if "token_version" in cols:
|
|
||||||
b.drop_column("token_version")
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"""Рейтинг (#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")
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Объявления администрации (#84): сами объявления и отметки «игрок закрыл».
|
|
||||||
|
|
||||||
Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
|
||||||
на существующей БД создаёт недостающие таблицы.
|
|
||||||
|
|
||||||
Отходит от 0013, а не от 0014: объявления выпущены в main раньше рейтинга. Ветки рейтинга
|
|
||||||
(0014) и объявлений (0015) сводит пустая миграция 0016.
|
|
||||||
|
|
||||||
Revision ID: 0015_announcements
|
|
||||||
Revises: 0013_user_token_version
|
|
||||||
Create Date: 2026-09-18
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "0015_announcements"
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
bind = op.get_bind()
|
|
||||||
tables = set(inspect(bind).get_table_names())
|
|
||||||
|
|
||||||
if "announcements" not in tables:
|
|
||||||
op.create_table(
|
|
||||||
"announcements",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
|
||||||
sa.Column("title", sa.String(64), nullable=False),
|
|
||||||
sa.Column("body_html", sa.Text(), nullable=False),
|
|
||||||
sa.Column("starts_at", sa.DateTime(), nullable=False),
|
|
||||||
sa.Column("ends_at", sa.DateTime(), nullable=False),
|
|
||||||
sa.Column("show_to_new_players", sa.Boolean(), nullable=False, server_default="1"),
|
|
||||||
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
|
|
||||||
sa.Column(
|
|
||||||
"created_by",
|
|
||||||
sa.Integer(),
|
|
||||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
|
||||||
sa.CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_announcements_period", "announcements", ["starts_at", "ends_at"])
|
|
||||||
|
|
||||||
if "announcement_views" not in tables:
|
|
||||||
op.create_table(
|
|
||||||
"announcement_views",
|
|
||||||
sa.Column(
|
|
||||||
"announcement_id",
|
|
||||||
sa.Integer(),
|
|
||||||
sa.ForeignKey("announcements.id", ondelete="CASCADE"),
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"user_id",
|
|
||||||
sa.Integer(),
|
|
||||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
sa.Column("revision", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("closed_at", sa.DateTime(), nullable=False),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_announcement_views_user_id", "announcement_views", ["user_id"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
tables = set(inspect(op.get_bind()).get_table_names())
|
|
||||||
if "announcement_views" in tables:
|
|
||||||
op.drop_table("announcement_views")
|
|
||||||
if "announcements" in tables:
|
|
||||||
op.drop_table("announcements")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Слияние веток миграций: рейтинг (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
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Вход администратора под защитой от перебора (#56).
|
|
||||||
|
|
||||||
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
|
||||||
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
|
||||||
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
|
||||||
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.core.errors import InvalidCredentialsError
|
|
||||||
from app.core.ratelimit import login_throttle
|
|
||||||
from app.models import User
|
|
||||||
from app.services import admin_service
|
|
||||||
|
|
||||||
# Неудач за окно LoginThrottle (15 минут):
|
|
||||||
_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса
|
|
||||||
_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса
|
|
||||||
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
|
||||||
# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход
|
|
||||||
# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий).
|
|
||||||
_ACCOUNT_LIMIT = 50
|
|
||||||
|
|
||||||
|
|
||||||
def _keys(ip: str, username: str) -> dict[str, int]:
|
|
||||||
pair_key = f"admin-login:{ip}:{username.casefold()}"
|
|
||||||
return {
|
|
||||||
pair_key: _PAIR_LIMIT,
|
|
||||||
f"admin-login-ip:{ip}": _IP_LIMIT,
|
|
||||||
f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def login_admin(session: Session, username: str, password: str, ip: str | None) -> User:
|
|
||||||
"""authenticate_admin под защитой от перебора. Сессию открывает вызывающий."""
|
|
||||||
username = (username or "").strip()
|
|
||||||
ip = ip or "unknown"
|
|
||||||
limits = _keys(ip, username)
|
|
||||||
login_throttle.check(limits)
|
|
||||||
try:
|
|
||||||
admin = admin_service.authenticate_admin(session, username, password)
|
|
||||||
except InvalidCredentialsError:
|
|
||||||
login_throttle.fail(limits)
|
|
||||||
raise
|
|
||||||
# Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит.
|
|
||||||
for key in limits:
|
|
||||||
login_throttle.reset(key)
|
|
||||||
return admin
|
|
||||||
@@ -21,14 +21,9 @@ def get_current_user(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||||
except jwt.PyJWTError as exc: # noqa: F841
|
except jwt.PyJWTError as exc: # noqa: F841
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
if security.is_session_revoked(payload):
|
|
||||||
raise AuthError("Сессия недействительна.")
|
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
|
||||||
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
|
||||||
raise AuthError("Сессия недействительна.")
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -42,11 +37,7 @@ def get_current_admin(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise AuthError("Сессия администратора недействительна.")
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
if security.is_session_revoked(payload):
|
|
||||||
raise AuthError("Сессия администратора недействительна.")
|
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or user.role != "admin" or not user.is_active:
|
if user is None or user.role != "admin" or not user.is_active:
|
||||||
raise ForbiddenError("Доступ только для администратора.")
|
raise ForbiddenError("Доступ только для администратора.")
|
||||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
|
||||||
raise AuthError("Сессия администратора недействительна.")
|
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def establish_session(
|
|||||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||||
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type]
|
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Вход игрока по логину (нику) и паролю.
|
|
||||||
|
|
||||||
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с dev-stub (вход по нику
|
|
||||||
без пароля) — тот живёт в dev_stub.py и в прод-образ не попадает. Сюда dev-код не импортировать.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.core.errors import (
|
|
||||||
InvalidCredentialsError,
|
|
||||||
ValidationError,
|
|
||||||
WrongCurrentPasswordError,
|
|
||||||
)
|
|
||||||
from app.core.ratelimit import login_throttle
|
|
||||||
from app.core.security import hash_password, verify_password
|
|
||||||
from app.models import User
|
|
||||||
|
|
||||||
PASSWORD_MIN_CHARS = 8
|
|
||||||
# bcrypt учитывает только первые 72 байта, а bcrypt 5 на более длинном пароле бросает
|
|
||||||
# ValueError — ограничиваем явно, с понятным сообщением.
|
|
||||||
_BCRYPT_MAX_BYTES = 72
|
|
||||||
|
|
||||||
# Лимиты неудач за окно LoginThrottle (15 минут): на пару «IP + логин» — против перебора
|
|
||||||
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
|
||||||
_PAIR_LIMIT = 5
|
|
||||||
_IP_LIMIT = 20
|
|
||||||
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
|
||||||
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
|
|
||||||
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
|
|
||||||
# успешный вход его сбрасывает.
|
|
||||||
_ACCOUNT_LIMIT = 50
|
|
||||||
# Неверный текущий пароль при смене — на аккаунт.
|
|
||||||
_CURRENT_PASSWORD_LIMIT = 5
|
|
||||||
# Регистраций с одного IP за окно — против спама аккаунтов (#62).
|
|
||||||
_REGISTER_IP_LIMIT = 10
|
|
||||||
|
|
||||||
|
|
||||||
def validate_new_password(password: str) -> None:
|
|
||||||
if len(password) < PASSWORD_MIN_CHARS:
|
|
||||||
raise ValidationError(f"Пароль: не короче {PASSWORD_MIN_CHARS} символов.")
|
|
||||||
if not password.strip():
|
|
||||||
raise ValidationError("Пароль не может состоять из одних пробелов.")
|
|
||||||
if len(password.encode("utf-8")) > _BCRYPT_MAX_BYTES:
|
|
||||||
raise ValidationError(
|
|
||||||
"Пароль слишком длинный: до 72 байт (72 латинских или 36 русских букв)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def _dummy_hash() -> str:
|
|
||||||
return hash_password("dummy-password-for-timing")
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_player(session: Session, nickname: str, password: str) -> User:
|
|
||||||
"""Игрок по нику и паролю. Админ так не входит: у него отдельный вход и cookie.
|
|
||||||
|
|
||||||
Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем: ответ занимает
|
|
||||||
столько же, сколько неверный пароль, и по времени нельзя узнать, есть ли такой логин."""
|
|
||||||
user = session.exec(
|
|
||||||
select(User).where(User.nickname == nickname, User.role == "player")
|
|
||||||
).first()
|
|
||||||
stored = user.password_hash if user is not None else None
|
|
||||||
valid = verify_password(password, stored or _dummy_hash())
|
|
||||||
if user is None or not stored or not valid:
|
|
||||||
raise InvalidCredentialsError()
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def login_player(session: Session, nickname: str, password: str, ip: str | None) -> User:
|
|
||||||
"""authenticate_player под защитой от перебора. Сессию открывает вызывающий."""
|
|
||||||
nickname = (nickname or "").strip()
|
|
||||||
ip = ip or "unknown"
|
|
||||||
pair_key = f"login:{ip}:{nickname.casefold()}"
|
|
||||||
account_key = f"login-user:{nickname.casefold()}"
|
|
||||||
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT}
|
|
||||||
login_throttle.check(limits)
|
|
||||||
try:
|
|
||||||
user = authenticate_player(session, nickname, password)
|
|
||||||
except InvalidCredentialsError:
|
|
||||||
login_throttle.fail(limits)
|
|
||||||
raise
|
|
||||||
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
|
|
||||||
# оставляем — он общий для всех логинов с адреса.
|
|
||||||
login_throttle.reset(pair_key)
|
|
||||||
login_throttle.reset(account_key)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def throttle_register(ip: str | None) -> None:
|
|
||||||
"""Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62).
|
|
||||||
|
|
||||||
Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов
|
|
||||||
с уникальными никами упирается в предел так же, как повторы по занятому нику.
|
|
||||||
Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе."""
|
|
||||||
ip = ip or "unknown"
|
|
||||||
limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT}
|
|
||||||
login_throttle.check(limits)
|
|
||||||
login_throttle.fail(limits)
|
|
||||||
|
|
||||||
|
|
||||||
def check_current_password(user: User, current_password: str | None) -> None:
|
|
||||||
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
|
|
||||||
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
|
|
||||||
key = f"current-password:{user.id}"
|
|
||||||
limits = {key: _CURRENT_PASSWORD_LIMIT}
|
|
||||||
login_throttle.check(limits)
|
|
||||||
if not current_password or not verify_password(current_password, user.password_hash or ""):
|
|
||||||
login_throttle.fail(limits)
|
|
||||||
raise WrongCurrentPasswordError()
|
|
||||||
login_throttle.reset(key)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Доступные методы входа по окружению.
|
"""Доступные методы входа по окружению.
|
||||||
|
|
||||||
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
|
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
|
||||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -9,7 +9,7 @@ from app.core.config import settings
|
|||||||
|
|
||||||
|
|
||||||
def enabled_methods() -> list[str]:
|
def enabled_methods() -> list[str]:
|
||||||
methods = ["password", "telegram"]
|
methods = ["telegram"]
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
methods.append("stub")
|
methods.append("stub")
|
||||||
return methods
|
return methods
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||||
|
|
||||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||||
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -14,7 +11,6 @@ from app.core.security import hash_password, verify_password
|
|||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.seed.reference_data import seed_reference_data
|
from app.seed.reference_data import seed_reference_data
|
||||||
from app.services import user_service
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_admin(session: Session) -> None:
|
def _ensure_admin(session: Session) -> None:
|
||||||
@@ -42,9 +38,7 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
|
|
||||||
# Администратор уже существует.
|
# Администратор уже существует.
|
||||||
if not settings.is_development:
|
if not settings.is_development:
|
||||||
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
|
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
||||||
# по себе ничего не делает — применить её можно только осознанно, командой
|
|
||||||
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||||
@@ -61,48 +55,11 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
||||||
|
|
||||||
|
|
||||||
def reset_admin_password(session: Session) -> User:
|
|
||||||
"""Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении.
|
|
||||||
|
|
||||||
Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт:
|
|
||||||
это единственный штатный способ, и он требует доступа к серверу. set_password
|
|
||||||
проверяет пароль и увеличивает token_version — все выданные админские сессии
|
|
||||||
(в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем."""
|
|
||||||
if not settings.admin_bootstrap_enabled:
|
|
||||||
raise RuntimeError(
|
|
||||||
"ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена."
|
|
||||||
)
|
|
||||||
password = (settings.admin_password or "").strip()
|
|
||||||
if not password:
|
|
||||||
raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.")
|
|
||||||
admin = session.exec(select(User).where(User.role == "admin")).first()
|
|
||||||
if admin is None:
|
|
||||||
raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.")
|
|
||||||
return user_service.set_password(session, admin, password)
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> None:
|
def bootstrap() -> None:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
seed_reference_data(session) # идемпотентно
|
seed_reference_data(session) # идемпотентно
|
||||||
_ensure_admin(session)
|
_ensure_admin(session)
|
||||||
|
|
||||||
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
bootstrap()
|
||||||
|
|||||||
+14
-68
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import field_validator, model_validator
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||||
@@ -12,15 +11,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||||
|
|
||||||
# Небезопасные значения по умолчанию (годятся только для dev на 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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -30,16 +20,16 @@ class Settings(BaseSettings):
|
|||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Главный переключатель окружения: development | production ─────────────
|
# ── Главный переключатель окружения: development | test | production ───────
|
||||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
||||||
|
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
|
||||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
# Публикация локального dev-окружения наружу через VPS-туннель.
|
# Публикация локального окружения (dev/test) наружу через VPS-туннель.
|
||||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
||||||
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
|
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
||||||
# от этого зависят Secure-cookie и проверка секретов при старте.
|
|
||||||
local_public: str = "local"
|
local_public: str = "local"
|
||||||
|
|
||||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
||||||
@@ -60,7 +50,7 @@ class Settings(BaseSettings):
|
|||||||
prod_achievements_dir: str = "/data/achievements"
|
prod_achievements_dir: str = "/data/achievements"
|
||||||
|
|
||||||
# JWT / cookie
|
# JWT / cookie
|
||||||
secret_key: str = _DEFAULT_SECRET_KEY
|
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||||
jwt_admin_ttl_minutes: int = 60 * 8
|
jwt_admin_ttl_minutes: int = 60 * 8
|
||||||
@@ -76,7 +66,7 @@ class Settings(BaseSettings):
|
|||||||
# Бутстрап администратора
|
# Бутстрап администратора
|
||||||
admin_bootstrap_enabled: bool = True
|
admin_bootstrap_enabled: bool = True
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
admin_password: str = "change-me-admin-password"
|
||||||
admin_nickname: str = "Администратор"
|
admin_nickname: str = "Администратор"
|
||||||
|
|
||||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||||
@@ -94,13 +84,17 @@ class Settings(BaseSettings):
|
|||||||
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
||||||
return self.app_env.lower() == "development"
|
return self.app_env.lower() == "development"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_test(self) -> bool:
|
||||||
|
return self.app_env.lower() == "test"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_production(self) -> bool:
|
def is_production(self) -> bool:
|
||||||
return self.app_env.lower() == "production"
|
return self.app_env.lower() == "production"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""БД: dev — отдельный файл дева; prod — том контейнера (/data)."""
|
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
|
||||||
return self.dev_database_url if self.is_development else self.prod_database_url
|
return self.dev_database_url if self.is_development else self.prod_database_url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -111,64 +105,16 @@ class Settings(BaseSettings):
|
|||||||
def achievements_dir(self) -> str:
|
def achievements_dir(self) -> str:
|
||||||
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
||||||
|
|
||||||
@property
|
|
||||||
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
|
@property
|
||||||
def cookie_secure(self) -> bool:
|
def cookie_secure(self) -> bool:
|
||||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
|
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
||||||
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
|
нативный dev на localhost по HTTP (development + local_public=local)."""
|
||||||
return self.is_published
|
return not (self.is_development and self.local_public.lower() == "local")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
|
|
||||||
@field_validator("app_env")
|
|
||||||
@classmethod
|
|
||||||
def _known_app_env(cls, value: str) -> str:
|
|
||||||
"""Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое
|
|
||||||
значение, кроме development, выбирает прод-пути к данным и выключает dev-вход."""
|
|
||||||
if value.lower() not in _APP_ENVS:
|
|
||||||
raise ValueError(
|
|
||||||
f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _forbid_default_secrets_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:
|
|
||||||
problems.append(
|
|
||||||
f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов"
|
|
||||||
)
|
|
||||||
if self.admin_bootstrap_enabled:
|
|
||||||
password = (self.admin_password or "").strip()
|
|
||||||
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(
|
|
||||||
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
|
|
||||||
+ "; ".join(problems)
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -136,41 +136,5 @@ class InvalidCredentialsError(AuthError):
|
|||||||
super().__init__("Неверный логин или пароль.")
|
super().__init__("Неверный логин или пароль.")
|
||||||
|
|
||||||
|
|
||||||
class TooManyAttemptsError(AppError):
|
|
||||||
"""Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд
|
|
||||||
ближайшая попытка снова будет принята."""
|
|
||||||
|
|
||||||
status_code = 429
|
|
||||||
code = "TOO_MANY_ATTEMPTS"
|
|
||||||
|
|
||||||
def __init__(self, retry_after: int) -> None:
|
|
||||||
minutes = max(1, -(-retry_after // 60))
|
|
||||||
super().__init__(
|
|
||||||
f"Слишком много неудачных попыток. Повторите через {minutes} мин.",
|
|
||||||
details={"retry_after": retry_after},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WrongCurrentPasswordError(ForbiddenError):
|
|
||||||
code = "WRONG_CURRENT_PASSWORD"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__("Текущий пароль неверен.")
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramAlreadyLinkedError(ConflictError):
|
|
||||||
code = "TELEGRAM_ALREADY_LINKED"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__("К аккаунту уже привязан Telegram.")
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramTakenError(ConflictError):
|
|
||||||
code = "TELEGRAM_TAKEN"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__("Этот Telegram уже привязан к другому аккаунту.")
|
|
||||||
|
|
||||||
|
|
||||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||||
return exc.to_response()
|
return exc.to_response()
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
|
||||||
|
|
||||||
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn
|
|
||||||
(`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это
|
|
||||||
приемлемо. Ограничения устойчивости (#60):
|
|
||||||
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
|
||||||
перезапуска, но окно короткое, а рестарты редки;
|
|
||||||
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
|
||||||
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
|
|
||||||
про SSE-шину), общий для всех воркеров.
|
|
||||||
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
|
||||||
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
|
||||||
(#58) не снимала защиту полностью.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
from app.core.errors import TooManyAttemptsError
|
|
||||||
|
|
||||||
_WINDOW_SECONDS = 15 * 60
|
|
||||||
# Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток
|
|
||||||
# с разных адресов не копил память бесконечно.
|
|
||||||
_PRUNE_ABOVE = 10_000
|
|
||||||
|
|
||||||
|
|
||||||
class LoginThrottle:
|
|
||||||
"""Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока).
|
|
||||||
|
|
||||||
Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком."""
|
|
||||||
|
|
||||||
def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None:
|
|
||||||
self.window = window_seconds
|
|
||||||
self._fails: dict[str, deque[float]] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def _recent(self, key: str, now: float) -> deque[float]:
|
|
||||||
attempts = self._fails.get(key)
|
|
||||||
if attempts is None:
|
|
||||||
return deque()
|
|
||||||
while attempts and now - attempts[0] >= self.window:
|
|
||||||
attempts.popleft()
|
|
||||||
if not attempts:
|
|
||||||
del self._fails[key]
|
|
||||||
return attempts
|
|
||||||
|
|
||||||
def check(self, limits: dict[str, int]) -> None:
|
|
||||||
"""Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан."""
|
|
||||||
now = time.monotonic()
|
|
||||||
with self._lock:
|
|
||||||
for key, limit in limits.items():
|
|
||||||
attempts = self._recent(key, now)
|
|
||||||
if len(attempts) >= limit:
|
|
||||||
# Попытка снова примется, когда из окна выпадет неудача, после которой
|
|
||||||
# в нём остаётся limit-1 записей.
|
|
||||||
frees_at = attempts[len(attempts) - limit] + self.window
|
|
||||||
raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1)
|
|
||||||
|
|
||||||
def fail(self, keys: dict[str, int]) -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
with self._lock:
|
|
||||||
if len(self._fails) > _PRUNE_ABOVE:
|
|
||||||
for key in list(self._fails):
|
|
||||||
self._recent(key, now)
|
|
||||||
for key in keys:
|
|
||||||
self._fails.setdefault(key, deque()).append(now)
|
|
||||||
|
|
||||||
def reset(self, key: str) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._fails.pop(key, None)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._fails.clear()
|
|
||||||
|
|
||||||
|
|
||||||
login_throttle = LoginThrottle()
|
|
||||||
@@ -9,7 +9,6 @@ import jwt
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.token_revocation import revoked_tokens
|
|
||||||
|
|
||||||
USER_COOKIE = "fs_session"
|
USER_COOKIE = "fs_session"
|
||||||
ADMIN_COOKIE = "fs_admin"
|
ADMIN_COOKIE = "fs_admin"
|
||||||
@@ -38,13 +37,7 @@ def verify_password(password: str, password_hash: str) -> bool:
|
|||||||
|
|
||||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_token(
|
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
||||||
subject: str | int,
|
|
||||||
audience: str,
|
|
||||||
ttl_minutes: int,
|
|
||||||
provider: str = "",
|
|
||||||
token_version: int = 0,
|
|
||||||
) -> str:
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
@@ -53,9 +46,6 @@ def create_token(
|
|||||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||||
"jti": secrets.token_hex(8),
|
"jti": secrets.token_hex(8),
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
|
||||||
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
|
||||||
"ver": token_version,
|
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
@@ -105,9 +95,9 @@ def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
|
|||||||
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
|
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
|
||||||
|
|
||||||
|
|
||||||
def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None:
|
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||||
ttl = settings.jwt_user_ttl_minutes
|
ttl = settings.jwt_user_ttl_minutes
|
||||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
|
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=USER_COOKIE,
|
key=USER_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -121,9 +111,9 @@ def set_user_session(response: Response, user_id: int, provider: str, token_vers
|
|||||||
_set_csrf_cookie(response)
|
_set_csrf_cookie(response)
|
||||||
|
|
||||||
|
|
||||||
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None:
|
def set_admin_session(response: Response, admin_id: int) -> None:
|
||||||
ttl = settings.jwt_admin_ttl_minutes
|
ttl = settings.jwt_admin_ttl_minutes
|
||||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
|
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=ADMIN_COOKIE,
|
key=ADMIN_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -145,33 +135,6 @@ def clear_admin_session(response: Response) -> None:
|
|||||||
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
||||||
|
|
||||||
|
|
||||||
def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None:
|
|
||||||
"""Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout.
|
|
||||||
|
|
||||||
Убивает именно этот токен (украденный/оставленный), не трогая другие устройства.
|
|
||||||
Некорректный/просроченный токен отзывать нечего — молча выходим."""
|
|
||||||
token = request.cookies.get(cookie_name)
|
|
||||||
if not token:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token,
|
|
||||||
settings.secret_key,
|
|
||||||
algorithms=[settings.jwt_algorithm],
|
|
||||||
audience=audience,
|
|
||||||
)
|
|
||||||
except jwt.PyJWTError:
|
|
||||||
return
|
|
||||||
exp = payload.get("exp")
|
|
||||||
if exp is not None:
|
|
||||||
revoked_tokens.revoke(payload.get("jti"), float(exp))
|
|
||||||
|
|
||||||
|
|
||||||
def is_session_revoked(payload: dict) -> bool:
|
|
||||||
"""Отозван ли этот токен точечно (через logout)."""
|
|
||||||
return revoked_tokens.is_revoked(payload.get("jti"))
|
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str | None:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""IP клиента для журнала аудита.
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
"""Точечный отзыв отдельных JWT по `jti` — для logout (выход именно этого токена).
|
|
||||||
|
|
||||||
Список живёт в памяти процесса, как throttle и SSE-шина: рассчитан на один воркер uvicorn.
|
|
||||||
Рестарт очищает список — это приемлемо: записи и так живут лишь до `exp` токена, а на новый
|
|
||||||
процесс приходят уже свежие cookie. Для «выйти со всех устройств» и отзыва при смене пароля
|
|
||||||
используется `token_version` у пользователя (см. models.User, auth/deps), а не этот список.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Выше этого числа записей при отзыве вычищаем протухшие, чтобы поток logout'ов не копил память.
|
|
||||||
_PRUNE_ABOVE = 10_000
|
|
||||||
|
|
||||||
|
|
||||||
class RevokedTokens:
|
|
||||||
"""Множество отозванных `jti` с временем истечения (unix-время, как `exp` в JWT)."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._revoked: dict[str, float] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def revoke(self, jti: str | None, expires_at: float) -> None:
|
|
||||||
if not jti:
|
|
||||||
return
|
|
||||||
now = time.time()
|
|
||||||
with self._lock:
|
|
||||||
if len(self._revoked) > _PRUNE_ABOVE:
|
|
||||||
for key, exp in list(self._revoked.items()):
|
|
||||||
if exp <= now:
|
|
||||||
del self._revoked[key]
|
|
||||||
self._revoked[jti] = expires_at
|
|
||||||
|
|
||||||
def is_revoked(self, jti: str | None) -> bool:
|
|
||||||
if not jti:
|
|
||||||
return False
|
|
||||||
now = time.time()
|
|
||||||
with self._lock:
|
|
||||||
exp = self._revoked.get(jti)
|
|
||||||
if exp is None:
|
|
||||||
return False
|
|
||||||
if exp <= now:
|
|
||||||
del self._revoked[jti] # протухла — заодно вычищаем
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._revoked.clear()
|
|
||||||
|
|
||||||
|
|
||||||
revoked_tokens = RevokedTokens()
|
|
||||||
+8
-28
@@ -17,7 +17,6 @@ from app.core.errors import AppError, app_error_handler
|
|||||||
from app.routers import (
|
from app.routers import (
|
||||||
achievements,
|
achievements,
|
||||||
admin,
|
admin,
|
||||||
announcements,
|
|
||||||
auth,
|
auth,
|
||||||
events,
|
events,
|
||||||
groups,
|
groups,
|
||||||
@@ -132,18 +131,8 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
hub.bind_loop(asyncio.get_running_loop())
|
hub.bind_loop(asyncio.get_running_loop())
|
||||||
|
|
||||||
if settings.is_development and settings.is_published:
|
|
||||||
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
|
|
||||||
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
|
|
||||||
logging.getLogger("fs").warning(
|
|
||||||
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
|
|
||||||
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
|
|
||||||
"и Swagger. Не держите в dev-базе копию прод-данных.",
|
|
||||||
settings.local_public,
|
|
||||||
)
|
|
||||||
|
|
||||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||||
try:
|
try:
|
||||||
from app.bootstrap import bootstrap
|
from app.bootstrap import bootstrap
|
||||||
@@ -162,21 +151,17 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для
|
|
||||||
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
|
||||||
# незачем облегчать разведку поверхности API анонимам (#61).
|
|
||||||
docs_enabled = not settings.is_production
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Forbidden Stars API",
|
title="Forbidden Stars API",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
openapi_url="/api/openapi.json" if docs_enabled else None,
|
openapi_url="/api/openapi.json",
|
||||||
docs_url="/api/docs" if docs_enabled else None,
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc" if docs_enabled else None,
|
redoc_url="/api/redoc",
|
||||||
lifespan=_lifespan,
|
lifespan=_lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
|
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
|
||||||
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||||
if settings.is_development and settings.cors_origins_list:
|
if settings.is_development and settings.cors_origins_list:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -192,17 +177,13 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
# Только type/loc/msg. В input лежит тело запроса: эхо паролей в ответ, а для тела
|
|
||||||
# не в JSON (text/plain от HTML-формы) — сырые bytes, которые JSON не сериализует,
|
|
||||||
# и ответ падал в 500. В ctx бывают объекты исключений — та же проблема.
|
|
||||||
details = [{"type": e["type"], "loc": e["loc"], "msg": e["msg"]} for e in exc.errors()]
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
content={
|
content={
|
||||||
"error": {
|
"error": {
|
||||||
"code": "VALIDATION_ERROR",
|
"code": "VALIDATION_ERROR",
|
||||||
"message": "Ошибка валидации запроса.",
|
"message": "Ошибка валидации запроса.",
|
||||||
"details": details,
|
"details": exc.errors(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -210,13 +191,12 @@ def create_app() -> FastAPI:
|
|||||||
# API-роутеры под /api.
|
# API-роутеры под /api.
|
||||||
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
||||||
matches.router, reference.router, stats.router, achievements.router,
|
matches.router, reference.router, stats.router, achievements.router,
|
||||||
events.router, notifications.router, announcements.router,
|
events.router, notifications.router, admin.router]
|
||||||
admin.router]
|
|
||||||
for r in api_routers:
|
for r in api_routers:
|
||||||
app.include_router(r, prefix="/api")
|
app.include_router(r, prefix="/api")
|
||||||
|
|
||||||
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
||||||
# и только если код физически есть (в прод-образе dev_*-файлы исключены
|
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
|
||||||
# .dockerignore, импорт просто не выполнится).
|
# .dockerignore, импорт просто не выполнится).
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
for mod_name in ("dev_auth", "dev_admin"):
|
for mod_name in ("dev_auth", "dev_admin"):
|
||||||
|
|||||||
+1
-90
@@ -93,13 +93,6 @@ class User(SQLModel, table=True):
|
|||||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||||
)
|
)
|
||||||
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
||||||
# Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя
|
|
||||||
# (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля
|
|
||||||
# и сбросе пароля админом — компрометация или утечка токена так прекращается (#57).
|
|
||||||
token_version: int = Field(
|
|
||||||
default=0,
|
|
||||||
sa_column=Column(Integer, nullable=False, server_default="0"),
|
|
||||||
)
|
|
||||||
active_group_id: int | None = Field(
|
active_group_id: int | None = Field(
|
||||||
sa_column=Column(
|
sa_column=Column(
|
||||||
Integer,
|
Integer,
|
||||||
@@ -195,12 +188,6 @@ class Group(SQLModel, table=True):
|
|||||||
sa_column_kwargs={"onupdate": utcnow},
|
sa_column_kwargs={"onupdate": utcnow},
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
|
|
||||||
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
|
|
||||||
nine_rounds_rule: bool = Field(
|
|
||||||
default=False,
|
|
||||||
sa_column=Column(Boolean, nullable=False, server_default="0"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMember(SQLModel, table=True):
|
class GroupMember(SQLModel, table=True):
|
||||||
@@ -281,8 +268,7 @@ class Match(SQLModel, table=True):
|
|||||||
Index("ix_matches_group_played", "group_id", "played_at"),
|
Index("ix_matches_group_played", "group_id", "played_at"),
|
||||||
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"win_reason IS NULL OR win_reason IN "
|
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
|
||||||
"('objectives','worlds','plastic','resources','last_standing')",
|
|
||||||
name="ck_match_win_reason",
|
name="ck_match_win_reason",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -302,14 +288,6 @@ class Match(SQLModel, table=True):
|
|||||||
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
|
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
|
||||||
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||||
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
|
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
|
||||||
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
|
|
||||||
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
|
|
||||||
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
|
||||||
# Снимок Group.nine_rounds_rule на момент старта партии.
|
|
||||||
nine_rounds_rule: bool = Field(
|
|
||||||
default=False,
|
|
||||||
sa_column=Column(Boolean, nullable=False, server_default="0"),
|
|
||||||
)
|
|
||||||
player_count: int = Field(sa_column=Column(Integer, nullable=False))
|
player_count: int = Field(sa_column=Column(Integer, nullable=False))
|
||||||
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||||
created_by: int = Field(
|
created_by: int = Field(
|
||||||
@@ -355,10 +333,6 @@ class MatchParticipant(SQLModel, table=True):
|
|||||||
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||||
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||||
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
|
|
||||||
# на конец партии. У выбывшего миров 0.
|
|
||||||
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
|
||||||
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
|
||||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -447,69 +421,6 @@ class Notification(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
# ─── Объявления администрации ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class Announcement(SQLModel, table=True):
|
|
||||||
"""Объявление администрации (#84): окно поверх приложения в период показа, каждому
|
|
||||||
игроку — пока он его не закроет.
|
|
||||||
|
|
||||||
body_html — уже очищенный сервером HTML (announcement_service.sanitize_body), фронт
|
|
||||||
вставляет его как есть. revision растёт, когда админ сохраняет правку с «показать
|
|
||||||
заново»: закрывшие прежнюю версию увидят объявление ещё раз с пометкой «обновлено».
|
|
||||||
show_to_new_players=False — не показывать зарегистрировавшимся после starts_at."""
|
|
||||||
|
|
||||||
__tablename__ = "announcements"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
|
|
||||||
Index("ix_announcements_period", "starts_at", "ends_at"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
|
||||||
title: str = Field(sa_column=Column(String(64), nullable=False))
|
|
||||||
body_html: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
starts_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
|
|
||||||
ends_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
|
|
||||||
show_to_new_players: bool = Field(
|
|
||||||
default=True,
|
|
||||||
sa_column=Column(Boolean, nullable=False, server_default="1"),
|
|
||||||
)
|
|
||||||
revision: int = Field(default=1, sa_column=Column(Integer, nullable=False, server_default="1"))
|
|
||||||
created_by: int | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
),
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
|
||||||
updated_at: datetime = Field(
|
|
||||||
default_factory=utcnow,
|
|
||||||
sa_column_kwargs={"onupdate": utcnow},
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementView(SQLModel, table=True):
|
|
||||||
"""Игрок закрыл объявление. revision — какую версию он видел: если админ выпустил
|
|
||||||
новую, объявление покажется снова."""
|
|
||||||
|
|
||||||
__tablename__ = "announcement_views"
|
|
||||||
|
|
||||||
announcement_id: int | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
Integer, ForeignKey("announcements.id", ondelete="CASCADE"), primary_key=True
|
|
||||||
),
|
|
||||||
)
|
|
||||||
user_id: int | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, index=True
|
|
||||||
),
|
|
||||||
)
|
|
||||||
revision: int = Field(sa_column=Column(Integer, nullable=False))
|
|
||||||
closed_at: datetime = Field(default_factory=utcnow, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AuditLog(SQLModel, table=True):
|
class AuditLog(SQLModel, table=True):
|
||||||
|
|||||||
+45
-188
@@ -5,20 +5,19 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.admin_login import login_admin
|
|
||||||
from app.auth.deps import get_current_admin
|
from app.auth.deps import get_current_admin
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.security import client_ip
|
from app.core.security import client_ip
|
||||||
from app.core.errors import InvalidCredentialsError, NotFoundError
|
from app.core.errors import NotFoundError
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.routers.matches import attachment_read, build_match_read, participant_inputs
|
from app.routers.matches import attachment_read, build_match_read
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
|
from app.services.match_service import ParticipantInput
|
||||||
from app.services import (
|
from app.services import (
|
||||||
achievement_service,
|
achievement_service,
|
||||||
admin_service,
|
admin_service,
|
||||||
announcement_service,
|
|
||||||
attachment_service,
|
attachment_service,
|
||||||
audit_service,
|
audit_service,
|
||||||
faction_service,
|
faction_service,
|
||||||
@@ -41,23 +40,8 @@ def admin_login(
|
|||||||
response: Response,
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> s.AdminMe:
|
) -> s.AdminMe:
|
||||||
try:
|
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
||||||
admin = login_admin(session, body.username, body.password, client_ip(request))
|
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
||||||
except InvalidCredentialsError:
|
|
||||||
# Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к
|
|
||||||
# полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56).
|
|
||||||
audit_service.record(
|
|
||||||
session,
|
|
||||||
actor_id=None,
|
|
||||||
action="login_failed",
|
|
||||||
entity_type="admin",
|
|
||||||
payload={"username": (body.username or "")[:64]},
|
|
||||||
ip=client_ip(request),
|
|
||||||
user_agent=request.headers.get("user-agent"),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
raise
|
|
||||||
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
|
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=admin.id,
|
actor_id=admin.id,
|
||||||
@@ -72,8 +56,7 @@ def admin_login(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
@router.post("/auth/logout", response_model=s.OkResponse)
|
||||||
def admin_logout(request: Request, response: Response) -> s.OkResponse:
|
def admin_logout(response: Response) -> s.OkResponse:
|
||||||
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
|
|
||||||
security.clear_admin_session(response)
|
security.clear_admin_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
@@ -85,26 +68,24 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
|||||||
|
|
||||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _admin_user_read(u: User) -> s.AdminUserRead:
|
|
||||||
return s.AdminUserRead(
|
|
||||||
id=u.id, # type: ignore[arg-type]
|
|
||||||
nickname=u.nickname,
|
|
||||||
role=u.role,
|
|
||||||
is_active=u.is_active,
|
|
||||||
auth_provider=u.auth_provider,
|
|
||||||
telegram_id=u.telegram_id,
|
|
||||||
created_at=iso_utc(u.created_at),
|
|
||||||
has_password=u.password_hash is not None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||||
def list_users(
|
def list_users(
|
||||||
query: str | None = Query(None),
|
query: str | None = Query(None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> list[s.AdminUserRead]:
|
) -> list[s.AdminUserRead]:
|
||||||
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
|
return [
|
||||||
|
s.AdminUserRead(
|
||||||
|
id=u.id, # type: ignore[arg-type]
|
||||||
|
nickname=u.nickname,
|
||||||
|
role=u.role,
|
||||||
|
is_active=u.is_active,
|
||||||
|
auth_provider=u.auth_provider,
|
||||||
|
telegram_id=u.telegram_id,
|
||||||
|
created_at=iso_utc(u.created_at),
|
||||||
|
)
|
||||||
|
for u in admin_service.list_users(session, query)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
||||||
@@ -126,34 +107,19 @@ def update_user(
|
|||||||
ip=client_ip(request),
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return _admin_user_read(u)
|
return s.AdminUserRead(
|
||||||
|
id=u.id, # type: ignore[arg-type]
|
||||||
|
nickname=u.nickname,
|
||||||
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
role=u.role,
|
||||||
def set_user_password(
|
is_active=u.is_active,
|
||||||
user_id: int,
|
auth_provider=u.auth_provider,
|
||||||
body: s.AdminPasswordSet,
|
telegram_id=u.telegram_id,
|
||||||
request: Request,
|
created_at=iso_utc(u.created_at),
|
||||||
session: Session = Depends(get_session),
|
|
||||||
admin: User = Depends(get_current_admin),
|
|
||||||
) -> s.AdminUserRead:
|
|
||||||
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
|
|
||||||
u = admin_service.set_player_password(session, user_id, body.new_password)
|
|
||||||
audit_service.record(
|
|
||||||
session,
|
|
||||||
actor_id=admin.id,
|
|
||||||
action="update",
|
|
||||||
entity_type="user",
|
|
||||||
entity_id=user_id,
|
|
||||||
payload={"password": "set_by_admin"},
|
|
||||||
ip=client_ip(request),
|
|
||||||
)
|
)
|
||||||
session.commit()
|
|
||||||
return _admin_user_read(u)
|
|
||||||
|
|
||||||
|
|
||||||
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||||
# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только
|
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
|
||||||
# отключается (PATCH is_active), удалять нельзя.
|
# отключается (PATCH is_active), удалять нельзя.
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +209,19 @@ def update_match(
|
|||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.MatchRead:
|
) -> s.MatchRead:
|
||||||
match = match_service.get_match(session, match_id)
|
match = match_service.get_match(session, match_id)
|
||||||
|
participants = None
|
||||||
|
if body.participants is not None:
|
||||||
|
participants = [
|
||||||
|
ParticipantInput(
|
||||||
|
user_id=p.user_id,
|
||||||
|
faction_id=p.faction_id,
|
||||||
|
place=p.place,
|
||||||
|
eliminated=p.eliminated,
|
||||||
|
was_random=p.was_random,
|
||||||
|
comment=p.comment,
|
||||||
|
)
|
||||||
|
for p in body.participants
|
||||||
|
]
|
||||||
match = match_service.update_match(
|
match = match_service.update_match(
|
||||||
session,
|
session,
|
||||||
match,
|
match,
|
||||||
@@ -251,9 +230,7 @@ def update_match(
|
|||||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||||
win_reason=body.win_reason,
|
win_reason=body.win_reason,
|
||||||
win_reason_set=("win_reason" in body.model_fields_set),
|
win_reason_set=("win_reason" in body.model_fields_set),
|
||||||
end_round=body.end_round,
|
participants=participants,
|
||||||
end_round_set=("end_round" in body.model_fields_set),
|
|
||||||
participants=participant_inputs(body),
|
|
||||||
expected_version=body.expected_version,
|
expected_version=body.expected_version,
|
||||||
)
|
)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
@@ -315,15 +292,16 @@ def delete_match(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.OkResponse:
|
) -> s.OkResponse:
|
||||||
match = match_service.get_match(session, match_id)
|
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
||||||
group_id, finished = match.group_id, match.status == "finished" # для уведомления
|
# До удаления: каскад унесёт участников вместе с партией.
|
||||||
|
participant_ids = notify.match_participant_ids(session, match_id)
|
||||||
admin_service.delete_match(session, match_id)
|
admin_service.delete_match(session, match_id)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||||
ip=client_ip(request),
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id, group_id, finished=finished)
|
notify.match_removed(session, match_id, group_id, participant_ids)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@@ -463,127 +441,6 @@ def delete_achievement(
|
|||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
# ─── Объявления ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _announcement_read(item: dict) -> s.AdminAnnouncementRead:
|
|
||||||
a = item["a"]
|
|
||||||
return s.AdminAnnouncementRead(
|
|
||||||
id=a.id,
|
|
||||||
title=a.title,
|
|
||||||
body_html=a.body_html,
|
|
||||||
starts_at=iso_utc(a.starts_at), # type: ignore[arg-type]
|
|
||||||
ends_at=iso_utc(a.ends_at), # type: ignore[arg-type]
|
|
||||||
show_to_new_players=a.show_to_new_players,
|
|
||||||
revision=a.revision,
|
|
||||||
status=item["status"],
|
|
||||||
seen_count=item["seen"],
|
|
||||||
audience_count=item["audience"],
|
|
||||||
created_at=iso_utc(a.created_at), # type: ignore[arg-type]
|
|
||||||
updated_at=iso_utc(a.updated_at), # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _announcement_by_id(session: Session, announcement_id: int) -> s.AdminAnnouncementRead:
|
|
||||||
return _announcement_read(announcement_service.admin_item(session, announcement_id))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/announcements", response_model=list[s.AdminAnnouncementRead])
|
|
||||||
def list_announcements(
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
_admin: User = Depends(get_current_admin),
|
|
||||||
) -> list[s.AdminAnnouncementRead]:
|
|
||||||
return [_announcement_read(i) for i in announcement_service.list_admin(session)]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/announcements", response_model=s.AdminAnnouncementRead)
|
|
||||||
def create_announcement(
|
|
||||||
body: s.AnnouncementWrite,
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
admin: User = Depends(get_current_admin),
|
|
||||||
) -> s.AdminAnnouncementRead:
|
|
||||||
a = announcement_service.create(
|
|
||||||
session,
|
|
||||||
title=body.title,
|
|
||||||
body_html=body.body_html,
|
|
||||||
starts_at=body.starts_at,
|
|
||||||
ends_at=body.ends_at,
|
|
||||||
show_to_new_players=body.show_to_new_players,
|
|
||||||
actor_id=admin.id,
|
|
||||||
)
|
|
||||||
audit_service.record(
|
|
||||||
session, actor_id=admin.id, action="create", entity_type="announcement",
|
|
||||||
entity_id=a.id, payload={"title": a.title}, ip=client_ip(request),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
notify.announcements_changed(session)
|
|
||||||
return _announcement_by_id(session, a.id) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/announcements/{announcement_id}", response_model=s.AdminAnnouncementRead)
|
|
||||||
def update_announcement(
|
|
||||||
announcement_id: int,
|
|
||||||
body: s.AnnouncementUpdate,
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
admin: User = Depends(get_current_admin),
|
|
||||||
) -> s.AdminAnnouncementRead:
|
|
||||||
a = announcement_service.update(
|
|
||||||
session,
|
|
||||||
announcement_id,
|
|
||||||
title=body.title,
|
|
||||||
body_html=body.body_html,
|
|
||||||
starts_at=body.starts_at,
|
|
||||||
ends_at=body.ends_at,
|
|
||||||
show_to_new_players=body.show_to_new_players,
|
|
||||||
reshow=body.reshow,
|
|
||||||
)
|
|
||||||
audit_service.record(
|
|
||||||
session, actor_id=admin.id, action="update", entity_type="announcement",
|
|
||||||
entity_id=announcement_id,
|
|
||||||
payload={"title": a.title, "reshow": body.reshow, "revision": a.revision},
|
|
||||||
ip=client_ip(request),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
notify.announcements_changed(session)
|
|
||||||
return _announcement_by_id(session, announcement_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/announcements/{announcement_id}/stop", response_model=s.AdminAnnouncementRead)
|
|
||||||
def stop_announcement(
|
|
||||||
announcement_id: int,
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
admin: User = Depends(get_current_admin),
|
|
||||||
) -> s.AdminAnnouncementRead:
|
|
||||||
"""«Снять с показа»: период идущего объявления заканчивается сейчас."""
|
|
||||||
announcement_service.stop(session, announcement_id)
|
|
||||||
audit_service.record(
|
|
||||||
session, actor_id=admin.id, action="update", entity_type="announcement",
|
|
||||||
entity_id=announcement_id, payload={"stopped": True}, ip=client_ip(request),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
notify.announcements_changed(session)
|
|
||||||
return _announcement_by_id(session, announcement_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/announcements/{announcement_id}", response_model=s.OkResponse)
|
|
||||||
def delete_announcement(
|
|
||||||
announcement_id: int,
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
admin: User = Depends(get_current_admin),
|
|
||||||
) -> s.OkResponse:
|
|
||||||
announcement_service.delete(session, announcement_id)
|
|
||||||
audit_service.record(
|
|
||||||
session, actor_id=admin.id, action="delete", entity_type="announcement",
|
|
||||||
entity_id=announcement_id, ip=client_ip(request),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
notify.announcements_changed(session)
|
|
||||||
return s.OkResponse()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/audit-logs", response_model=s.AuditLogList)
|
@router.get("/audit-logs", response_model=s.AuditLogList)
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Объявления администрации для игрока: что показать сейчас и «Понятно».
|
|
||||||
|
|
||||||
Появление нового объявления у открытой вкладки обеспечивает SSE-сигнал
|
|
||||||
`{type:"announcements"}`; объявление с отложенным началом клиент подхватывает
|
|
||||||
периодическим перезапросом."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models import User
|
|
||||||
from app.schemas import api as s
|
|
||||||
from app.services import announcement_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/announcements", tags=["announcements"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pending", response_model=list[s.AnnouncementRead])
|
|
||||||
def pending(
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
) -> list[s.AnnouncementRead]:
|
|
||||||
return [
|
|
||||||
s.AnnouncementRead(
|
|
||||||
id=a.id, # type: ignore[arg-type]
|
|
||||||
title=a.title,
|
|
||||||
body_html=a.body_html,
|
|
||||||
revision=a.revision,
|
|
||||||
updated=updated,
|
|
||||||
)
|
|
||||||
for a, updated in announcement_service.pending_for_user(session, user)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{announcement_id}/ack", response_model=s.OkResponse)
|
|
||||||
def acknowledge(
|
|
||||||
announcement_id: int,
|
|
||||||
body: s.AnnouncementAck,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
) -> s.OkResponse:
|
|
||||||
announcement_service.acknowledge(session, user.id, announcement_id, body.revision) # type: ignore[arg-type]
|
|
||||||
session.commit()
|
|
||||||
return s.OkResponse()
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход.
|
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
|
||||||
|
|
||||||
Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,17 +8,15 @@ from fastapi import APIRouter, Depends, Request, Response
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.login import establish_session
|
from app.auth.login import establish_session
|
||||||
from app.auth.password import login_player, throttle_register
|
|
||||||
from app.auth.registry import enabled_methods
|
from app.auth.registry import enabled_methods
|
||||||
from app.auth.telegram import TelegramProvider
|
from app.auth.telegram import TelegramProvider
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import TelegramNicknameRequiredError
|
from app.core.errors import TelegramNicknameRequiredError
|
||||||
from app.core.security import client_ip
|
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.routers.users import build_me
|
from app.routers.users import build_me
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
from app.services import audit_service, user_service
|
from app.services import user_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -31,43 +29,6 @@ def auth_config() -> s.AuthConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=s.MeRead)
|
|
||||||
def password_register(
|
|
||||||
body: s.PasswordRegister,
|
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
) -> s.MeRead:
|
|
||||||
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
|
|
||||||
throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62)
|
|
||||||
user = user_service.register_local(session, body.nickname, body.password)
|
|
||||||
audit_service.record(
|
|
||||||
session,
|
|
||||||
actor_id=user.id,
|
|
||||||
action="create",
|
|
||||||
entity_type="user",
|
|
||||||
entity_id=user.id,
|
|
||||||
payload={"provider": "local"},
|
|
||||||
ip=client_ip(request),
|
|
||||||
user_agent=request.headers.get("user-agent"),
|
|
||||||
)
|
|
||||||
establish_session(session, response, request, user, "local")
|
|
||||||
return build_me(session, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=s.MeRead)
|
|
||||||
def password_login(
|
|
||||||
body: s.PasswordLogin,
|
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
) -> s.MeRead:
|
|
||||||
"""Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429."""
|
|
||||||
user = login_player(session, body.nickname, body.password, client_ip(request))
|
|
||||||
establish_session(session, response, request, user, "local")
|
|
||||||
return build_me(session, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/telegram", response_model=s.MeRead)
|
@router.post("/telegram", response_model=s.MeRead)
|
||||||
def telegram_login(
|
def telegram_login(
|
||||||
body: s.TelegramAuthPayload,
|
body: s.TelegramAuthPayload,
|
||||||
@@ -117,9 +78,6 @@ def telegram_register(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout", response_model=s.OkResponse)
|
@router.post("/logout", response_model=s.OkResponse)
|
||||||
def logout(request: Request, response: Response) -> s.OkResponse:
|
def logout(response: Response) -> s.OkResponse:
|
||||||
# Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная
|
|
||||||
# cookie перестаёт работать сразу, а не живёт до конца TTL (#57).
|
|
||||||
security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER)
|
|
||||||
security.clear_user_session(response)
|
security.clear_user_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||||||
|
|
||||||
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
|
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
|
||||||
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||||||
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||||||
возможность удаления не попадает в прод — там аккаунт можно только
|
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
|
||||||
отключить (PATCH is_active).
|
отключить (PATCH is_active).
|
||||||
|
|
||||||
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
|
|||||||
owner_id=group.owner_id,
|
owner_id=group.owner_id,
|
||||||
my_role=member.role,
|
my_role=member.role,
|
||||||
expansion_ids=group_service.group_expansion_ids(session, group_id),
|
expansion_ids=group_service.group_expansion_ids(session, group_id),
|
||||||
nine_rounds_rule=group.nine_rounds_rule,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,19 +79,15 @@ def get_group(
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{group_id}", response_model=s.GroupDetail)
|
@router.patch("/{group_id}", response_model=s.GroupDetail)
|
||||||
def update_group(
|
def rename_group(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
body: s.GroupUpdate,
|
body: s.GroupRename,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> s.GroupDetail:
|
) -> s.GroupDetail:
|
||||||
"""Название и домашние правила группы; меняются только переданные поля."""
|
|
||||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||||
group = group_service.get_group(session, group_id)
|
group = group_service.get_group(session, group_id)
|
||||||
if body.name is not None:
|
group_service.rename_group(session, group, body.name)
|
||||||
group_service.rename_group(session, group, body.name)
|
|
||||||
if body.nine_rounds_rule is not None:
|
|
||||||
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
|
|
||||||
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,30 +22,10 @@ from app.services import (
|
|||||||
user_service,
|
user_service,
|
||||||
)
|
)
|
||||||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||||
from app.services.scoring import max_rounds
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
router = APIRouter(prefix="/matches", tags=["matches"])
|
||||||
|
|
||||||
|
|
||||||
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
|
|
||||||
"""Участники правки — общее для игроцкого и админского PATCH."""
|
|
||||||
if body.participants is None:
|
|
||||||
return None
|
|
||||||
return [
|
|
||||||
ParticipantInput(
|
|
||||||
user_id=p.user_id,
|
|
||||||
faction_id=p.faction_id,
|
|
||||||
place=p.place,
|
|
||||||
eliminated=p.eliminated,
|
|
||||||
was_random=p.was_random,
|
|
||||||
comment=p.comment,
|
|
||||||
objectives=p.objectives,
|
|
||||||
worlds=p.worlds,
|
|
||||||
)
|
|
||||||
for p in body.participants
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
|
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
|
||||||
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
|
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
|
||||||
@@ -76,8 +56,6 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
|||||||
eliminated=p.eliminated,
|
eliminated=p.eliminated,
|
||||||
was_random=p.was_random,
|
was_random=p.was_random,
|
||||||
comment=p.comment,
|
comment=p.comment,
|
||||||
objectives=p.objectives,
|
|
||||||
worlds=p.worlds,
|
|
||||||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
|
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
|
||||||
@@ -101,9 +79,6 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
|||||||
finished_at=iso_utc(match.finished_at),
|
finished_at=iso_utc(match.finished_at),
|
||||||
duration_minutes=match.duration_minutes,
|
duration_minutes=match.duration_minutes,
|
||||||
win_reason=match.win_reason, # type: ignore[arg-type]
|
win_reason=match.win_reason, # type: ignore[arg-type]
|
||||||
end_round=match.end_round,
|
|
||||||
nine_rounds_rule=match.nine_rounds_rule,
|
|
||||||
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
|
|
||||||
player_count=match.player_count,
|
player_count=match.player_count,
|
||||||
overall_comment=match.overall_comment,
|
overall_comment=match.overall_comment,
|
||||||
created_by=match.created_by,
|
created_by=match.created_by,
|
||||||
@@ -182,8 +157,6 @@ def finish_match(
|
|||||||
eliminated=p.eliminated,
|
eliminated=p.eliminated,
|
||||||
comment=p.comment,
|
comment=p.comment,
|
||||||
faction_id=p.faction_id,
|
faction_id=p.faction_id,
|
||||||
objectives=p.objectives,
|
|
||||||
worlds=p.worlds,
|
|
||||||
)
|
)
|
||||||
for p in body.participants
|
for p in body.participants
|
||||||
]
|
]
|
||||||
@@ -192,7 +165,6 @@ def finish_match(
|
|||||||
match,
|
match,
|
||||||
finish=finish,
|
finish=finish,
|
||||||
win_reason=body.win_reason,
|
win_reason=body.win_reason,
|
||||||
end_round=body.end_round,
|
|
||||||
overall_comment=body.overall_comment,
|
overall_comment=body.overall_comment,
|
||||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||||
expected_version=body.expected_version,
|
expected_version=body.expected_version,
|
||||||
@@ -234,6 +206,19 @@ def update_match(
|
|||||||
) -> s.MatchRead:
|
) -> s.MatchRead:
|
||||||
match = match_service.get_match(session, match_id)
|
match = match_service.get_match(session, match_id)
|
||||||
match_service.assert_can_modify(session, match, user)
|
match_service.assert_can_modify(session, match, user)
|
||||||
|
participants = None
|
||||||
|
if body.participants is not None:
|
||||||
|
participants = [
|
||||||
|
ParticipantInput(
|
||||||
|
user_id=p.user_id,
|
||||||
|
faction_id=p.faction_id,
|
||||||
|
place=p.place,
|
||||||
|
eliminated=p.eliminated,
|
||||||
|
was_random=p.was_random,
|
||||||
|
comment=p.comment,
|
||||||
|
)
|
||||||
|
for p in body.participants
|
||||||
|
]
|
||||||
match = match_service.update_match(
|
match = match_service.update_match(
|
||||||
session,
|
session,
|
||||||
match,
|
match,
|
||||||
@@ -242,9 +227,7 @@ def update_match(
|
|||||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||||
win_reason=body.win_reason,
|
win_reason=body.win_reason,
|
||||||
win_reason_set=("win_reason" in body.model_fields_set),
|
win_reason_set=("win_reason" in body.model_fields_set),
|
||||||
end_round=body.end_round,
|
participants=participants,
|
||||||
end_round_set=("end_round" in body.model_fields_set),
|
|
||||||
participants=participant_inputs(body),
|
|
||||||
expected_version=body.expected_version,
|
expected_version=body.expected_version,
|
||||||
)
|
)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
@@ -352,7 +335,9 @@ def delete_match(
|
|||||||
match_service.assert_can_modify(session, match, user)
|
match_service.assert_can_modify(session, match, user)
|
||||||
match_id_val = match.id
|
match_id_val = match.id
|
||||||
group_id_val = match.group_id
|
group_id_val = match.group_id
|
||||||
finished = match.status == "finished" # после удаления статус уже не прочитать
|
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
|
||||||
|
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
|
||||||
|
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
|
||||||
match_service.delete_match(session, match, expected_version=expected_version)
|
match_service.delete_match(session, match, expected_version=expected_version)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
@@ -365,5 +350,5 @@ def delete_match(
|
|||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
|
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"""Роутер текущего пользователя."""
|
"""Роутер текущего пользователя."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
from app.auth.password import check_current_password
|
from app.core.security import client_ip
|
||||||
from app.auth.telegram import TelegramProvider
|
|
||||||
from app.core.security import client_ip, set_user_session
|
|
||||||
from app.core.errors import NotFoundError
|
from app.core.errors import NotFoundError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -38,7 +36,6 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
history_mode=user.history_mode,
|
history_mode=user.history_mode,
|
||||||
history_detail=user.history_detail,
|
history_detail=user.history_detail,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
has_password=user.password_hash is not None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,60 +115,6 @@ def update_my_profile(
|
|||||||
return build_me(session, user)
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me/password", response_model=s.MeRead)
|
|
||||||
def change_my_password(
|
|
||||||
body: s.PasswordChange,
|
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
) -> s.MeRead:
|
|
||||||
"""Задать пароль (первый раз — без текущего) или сменить его (нужен текущий)."""
|
|
||||||
had_password = user.password_hash is not None
|
|
||||||
if had_password:
|
|
||||||
check_current_password(user, body.current_password)
|
|
||||||
user_service.set_password(session, user, body.new_password)
|
|
||||||
# set_password инкрементит token_version → все ранее выданные токены отозваны (#57).
|
|
||||||
# Перевыдаём cookie этому устройству со свежим ver, чтобы разлогинить только остальные.
|
|
||||||
set_user_session(response, user.id, user.auth_provider, user.token_version)
|
|
||||||
audit_service.record(
|
|
||||||
session,
|
|
||||||
actor_id=user.id,
|
|
||||||
action="update",
|
|
||||||
entity_type="user",
|
|
||||||
entity_id=user.id,
|
|
||||||
payload={"password": "changed" if had_password else "set"},
|
|
||||||
ip=client_ip(request),
|
|
||||||
user_agent=request.headers.get("user-agent"),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return build_me(session, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/telegram", response_model=s.MeRead)
|
|
||||||
def link_my_telegram(
|
|
||||||
body: s.TelegramAuthPayload,
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
) -> s.MeRead:
|
|
||||||
"""Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе."""
|
|
||||||
identity = TelegramProvider().authenticate(body.model_dump())
|
|
||||||
user_service.link_telegram(session, user, identity)
|
|
||||||
audit_service.record(
|
|
||||||
session,
|
|
||||||
actor_id=user.id,
|
|
||||||
action="update",
|
|
||||||
entity_type="user",
|
|
||||||
entity_id=user.id,
|
|
||||||
payload={"telegram": "linked"},
|
|
||||||
ip=client_ip(request),
|
|
||||||
user_agent=request.headers.get("user-agent"),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return build_me(session, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me/avatar", response_model=s.MeRead)
|
@router.put("/me/avatar", response_model=s.MeRead)
|
||||||
def upload_my_avatar(
|
def upload_my_avatar(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
|||||||
+8
-131
@@ -1,51 +1,22 @@
|
|||||||
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date
|
||||||
from typing import Annotated, Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
|
WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
||||||
# тогда, когда невыбывший участник один (match_service._check_last_standing).
|
|
||||||
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
|
|
||||||
|
|
||||||
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
|
|
||||||
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
|
|
||||||
EndRound = Annotated[int, Field(ge=1, le=9)]
|
|
||||||
Count = Annotated[int, Field(ge=0, le=99)]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
||||||
methods: list[str] = []
|
methods: list[str] = []
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
|
||||||
# Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py.
|
|
||||||
_PASSWORD_MAX_CHARS = 128
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordLogin(BaseModel):
|
|
||||||
# Логин — это ник игрока.
|
|
||||||
nickname: str
|
|
||||||
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordRegister(BaseModel):
|
|
||||||
nickname: str
|
|
||||||
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordChange(BaseModel):
|
|
||||||
# current_password нужен, только если пароль уже задан; первая установка — без него.
|
|
||||||
current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS)
|
|
||||||
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramAuthPayload(BaseModel):
|
class TelegramAuthPayload(BaseModel):
|
||||||
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
@@ -128,9 +99,6 @@ class UserRead(BaseModel):
|
|||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
groups: list[GroupBrief] = []
|
groups: list[GroupBrief] = []
|
||||||
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
|
|
||||||
# фронт не пускает дальше окна установки пароля.
|
|
||||||
has_password: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class NicknameUpdate(BaseModel):
|
class NicknameUpdate(BaseModel):
|
||||||
@@ -169,10 +137,8 @@ class GroupCreate(BaseModel):
|
|||||||
expansion_ids: list[int] = []
|
expansion_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class GroupUpdate(BaseModel):
|
class GroupRename(BaseModel):
|
||||||
# Частичная правка: переданные поля меняются, остальные остаются как есть.
|
name: str
|
||||||
name: str | None = None
|
|
||||||
nine_rounds_rule: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class GroupExpansionsUpdate(BaseModel):
|
class GroupExpansionsUpdate(BaseModel):
|
||||||
@@ -185,8 +151,6 @@ class GroupDetail(BaseModel):
|
|||||||
owner_id: int
|
owner_id: int
|
||||||
my_role: str
|
my_role: str
|
||||||
expansion_ids: list[int] = []
|
expansion_ids: list[int] = []
|
||||||
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
|
|
||||||
nine_rounds_rule: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MemberRead(BaseModel):
|
class MemberRead(BaseModel):
|
||||||
@@ -233,23 +197,6 @@ class NotificationMarkRead(BaseModel):
|
|||||||
ids: list[int] | None = None
|
ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
# ─── Объявления (игрок) ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class AnnouncementRead(BaseModel):
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
# HTML, уже очищенный сервером по белому списку — фронт вставляет как есть.
|
|
||||||
body_html: str
|
|
||||||
revision: int
|
|
||||||
# Игрок закрывал прежнюю версию — окно показывает пометку «обновлено».
|
|
||||||
updated: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementAck(BaseModel):
|
|
||||||
# Версия, которую игрок видел и закрыл (AnnouncementRead.revision).
|
|
||||||
revision: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Партии ──────────────────────────────────────────────────────────────────
|
# ─── Партии ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class RandomizeRequest(BaseModel):
|
class RandomizeRequest(BaseModel):
|
||||||
@@ -280,20 +227,17 @@ class MatchFinishParticipant(BaseModel):
|
|||||||
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
faction_id: int | None = None # опц. смена фракции при завершении
|
faction_id: int | None = None # опц. смена фракции при завершении
|
||||||
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
|
|
||||||
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
|
|
||||||
|
|
||||||
|
|
||||||
class MatchFinish(BaseModel):
|
class MatchFinish(BaseModel):
|
||||||
participants: list[MatchFinishParticipant]
|
participants: list[MatchFinishParticipant]
|
||||||
win_reason: WinReason
|
win_reason: WinReason
|
||||||
end_round: EndRound | None = None # раунд, в котором партия закончилась
|
|
||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
|
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
|
||||||
expected_version: str | None = None
|
expected_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# Полный участник (правка результатов завершённой партии).
|
# Полный участник (правка завершённой партии админом).
|
||||||
class ParticipantInput(BaseModel):
|
class ParticipantInput(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
faction_id: int
|
faction_id: int
|
||||||
@@ -301,15 +245,12 @@ class ParticipantInput(BaseModel):
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool = False
|
was_random: bool = False
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
objectives: Count | None = None
|
|
||||||
worlds: Count | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MatchUpdate(BaseModel):
|
class MatchUpdate(BaseModel):
|
||||||
played_at: date | None = None
|
played_at: date | None = None
|
||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
win_reason: WinReason | None = None
|
win_reason: WinReason | None = None
|
||||||
end_round: EndRound | None = None
|
|
||||||
participants: list[ParticipantInput] | None = None
|
participants: list[ParticipantInput] | None = None
|
||||||
expected_version: str | None = None # оптимистичная блокировка
|
expected_version: str | None = None # оптимистичная блокировка
|
||||||
|
|
||||||
@@ -323,8 +264,6 @@ class MatchParticipantRead(BaseModel):
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool
|
was_random: bool
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
objectives: int | None = None
|
|
||||||
worlds: int | None = None
|
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -347,10 +286,6 @@ class MatchFinishDraftData(BaseModel):
|
|||||||
comments: dict[str, str] = {}
|
comments: dict[str, str] = {}
|
||||||
win_reason: WinReason | None = None
|
win_reason: WinReason | None = None
|
||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
end_round: EndRound | None = None
|
|
||||||
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
|
|
||||||
objectives: dict[str, Count] = {}
|
|
||||||
worlds: dict[str, Count] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class MatchFinishDraftRead(BaseModel):
|
class MatchFinishDraftRead(BaseModel):
|
||||||
@@ -369,11 +304,6 @@ class MatchRead(BaseModel):
|
|||||||
finished_at: str | None = None
|
finished_at: str | None = None
|
||||||
duration_minutes: int | None = None
|
duration_minutes: int | None = None
|
||||||
win_reason: WinReason | None = None
|
win_reason: WinReason | None = None
|
||||||
end_round: int | None = None
|
|
||||||
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
|
|
||||||
# фронт берёт лимит отсюда, а не повторяет правило у себя.
|
|
||||||
nine_rounds_rule: bool = False
|
|
||||||
max_rounds: int
|
|
||||||
player_count: int
|
player_count: int
|
||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
created_by: int
|
created_by: int
|
||||||
@@ -392,8 +322,7 @@ class OverallStats(BaseModel):
|
|||||||
wins: int
|
wins: int
|
||||||
win_rate: float
|
win_rate: float
|
||||||
avg_place: float | None = None
|
avg_place: float | None = None
|
||||||
# Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
|
score: float | None = None
|
||||||
score: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntry(OverallStats):
|
class LeaderboardEntry(OverallStats):
|
||||||
@@ -401,9 +330,6 @@ class LeaderboardEntry(OverallStats):
|
|||||||
nickname: str
|
nickname: str
|
||||||
rank: int | None = None
|
rank: int | None = None
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
|
|
||||||
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
|
|
||||||
rating_confirmed: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MatchHistory(BaseModel):
|
class MatchHistory(BaseModel):
|
||||||
@@ -437,8 +363,6 @@ class FactionStat(BaseModel):
|
|||||||
wins: int
|
wins: int
|
||||||
win_rate: float
|
win_rate: float
|
||||||
avg_place: float | None = None
|
avg_place: float | None = None
|
||||||
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
|
|
||||||
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
|
|
||||||
score: float | None = None
|
score: float | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -502,8 +426,6 @@ class MatchListParticipant(BaseModel):
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool
|
was_random: bool
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
objectives: int | None = None
|
|
||||||
worlds: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MatchListItem(BaseModel):
|
class MatchListItem(BaseModel):
|
||||||
@@ -518,9 +440,6 @@ class MatchListItem(BaseModel):
|
|||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
created_by: int
|
created_by: int
|
||||||
participants: list[MatchListParticipant] = []
|
participants: list[MatchListParticipant] = []
|
||||||
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
|
|
||||||
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
|
|
||||||
rating_delta: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MatchList(BaseModel):
|
class MatchList(BaseModel):
|
||||||
@@ -574,7 +493,6 @@ class AdminUserRead(BaseModel):
|
|||||||
auth_provider: str
|
auth_provider: str
|
||||||
telegram_id: int | None = None
|
telegram_id: int | None = None
|
||||||
created_at: str
|
created_at: str
|
||||||
has_password: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class AdminUserUpdate(BaseModel):
|
class AdminUserUpdate(BaseModel):
|
||||||
@@ -582,11 +500,6 @@ class AdminUserUpdate(BaseModel):
|
|||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class AdminPasswordSet(BaseModel):
|
|
||||||
# Новый пароль игроку от админа — способ восстановить забытый пароль.
|
|
||||||
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
|
||||||
|
|
||||||
|
|
||||||
class AdminGroupRead(BaseModel):
|
class AdminGroupRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
@@ -644,39 +557,3 @@ class AuditLogList(BaseModel):
|
|||||||
items: list[AuditLogItem] = []
|
items: list[AuditLogItem] = []
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
# ─── Объявления (админ) ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
AnnouncementStatus = Literal["planned", "live", "finished"]
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementWrite(BaseModel):
|
|
||||||
# Точные правила (заголовок до 60 символов, текст до 600 видимых) — в
|
|
||||||
# announcement_service; здесь только отсечка мегабайтных тел.
|
|
||||||
title: str = Field(max_length=200)
|
|
||||||
body_html: str = Field(max_length=20_000)
|
|
||||||
starts_at: datetime
|
|
||||||
ends_at: datetime
|
|
||||||
show_to_new_players: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementUpdate(AnnouncementWrite):
|
|
||||||
# Показать заново тем, кто уже закрыл (с пометкой «обновлено»).
|
|
||||||
reshow: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class AdminAnnouncementRead(BaseModel):
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
body_html: str
|
|
||||||
starts_at: str
|
|
||||||
ends_at: str
|
|
||||||
show_to_new_players: bool
|
|
||||||
revision: int
|
|
||||||
status: AnnouncementStatus
|
|
||||||
# Закрыли текущую версию / сколько активных игроков в адресатах.
|
|
||||||
seen_count: int
|
|
||||||
audience_count: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|||||||
@@ -33,13 +33,7 @@ FACTIONS: list[tuple[str, str, str, int]] = [
|
|||||||
|
|
||||||
|
|
||||||
def seed_reference_data(session: Session) -> None:
|
def seed_reference_data(session: Session) -> None:
|
||||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
|
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
|
||||||
|
|
||||||
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
|
|
||||||
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
|
|
||||||
и после создания записи источник правды для него — БД (#72). Имя из кода получает
|
|
||||||
только новая фракция; поправить имя существующей — админкой или миграцией.
|
|
||||||
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
|
|
||||||
code_to_expansion: dict[str, Expansion] = {}
|
code_to_expansion: dict[str, Expansion] = {}
|
||||||
|
|
||||||
for code, name_ru, is_base, order in EXPANSIONS:
|
for code, name_ru, is_base, order in EXPANSIONS:
|
||||||
@@ -67,6 +61,7 @@ def seed_reference_data(session: Session) -> None:
|
|||||||
)
|
)
|
||||||
session.add(fac)
|
session.add(fac)
|
||||||
else:
|
else:
|
||||||
|
fac.name_ru = name_ru
|
||||||
fac.expansion_id = expansion.id # type: ignore[assignment]
|
fac.expansion_id = expansion.id # type: ignore[assignment]
|
||||||
fac.sort_order = order
|
fac.sort_order = order
|
||||||
|
|
||||||
|
|||||||
@@ -62,20 +62,8 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
|
||||||
"""Новый пароль игроку (восстановление забытого). Пароль админа через панель не
|
|
||||||
меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
|
|
||||||
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
|
|
||||||
user = session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise NotFoundError("Пользователь не найден.")
|
|
||||||
if user.role != "player":
|
|
||||||
raise ValidationError("Пароль администратора здесь не меняется.")
|
|
||||||
return user_service.set_password(session, user, new_password)
|
|
||||||
|
|
||||||
|
|
||||||
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
|
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
"""Объявления администрации (#84): очистка текста, период показа, кому и что показать.
|
|
||||||
|
|
||||||
Объявление видно игроку, пока идёт его период и игрок не закрыл текущую версию. Закрытие
|
|
||||||
(«Понятно») пишет отметку с номером версии; правка с «показать заново» поднимает версию —
|
|
||||||
и закрывшие прежнюю увидят объявление снова, с пометкой «обновлено».
|
|
||||||
|
|
||||||
Текст хранится HTML-ом из редактора админки, но только после очистки по белому списку
|
|
||||||
(sanitize_body): фронт вставляет его без экранирования, так что это единственный барьер
|
|
||||||
между полем редактора и страницей игрока.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import html
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.core.errors import NotFoundError, ValidationError
|
|
||||||
from app.core.timeutil import utcnow
|
|
||||||
from app.models import Announcement, AnnouncementView, User
|
|
||||||
|
|
||||||
TITLE_MAX = 60
|
|
||||||
TEXT_MAX = 600 # видимых символов, без разметки
|
|
||||||
|
|
||||||
# ─── Очистка HTML ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Что оставляем и во что превращаем. Редактор (contenteditable + execCommand) в разных
|
|
||||||
# браузерах пишет то <b>, то <strong>, абзацы — <div> или <p>; приводим к одному виду.
|
|
||||||
_TAGS = {
|
|
||||||
"b": "b",
|
|
||||||
"strong": "b",
|
|
||||||
"i": "em",
|
|
||||||
"em": "em",
|
|
||||||
"mark": "mark",
|
|
||||||
"p": "p",
|
|
||||||
"div": "p",
|
|
||||||
"br": "br",
|
|
||||||
}
|
|
||||||
# Теги, которые выбрасываются вместе с содержимым: их текст не предназначен для показа.
|
|
||||||
_DROP_WITH_CONTENT = {
|
|
||||||
"script", "style", "template", "noscript", "iframe", "object", "embed",
|
|
||||||
"svg", "math", "head", "title", "textarea", "select",
|
|
||||||
}
|
|
||||||
_VOID = {"br"}
|
|
||||||
|
|
||||||
|
|
||||||
class _Sanitizer(HTMLParser):
|
|
||||||
"""Пересобирает HTML из разобранных токенов: теги — только из белого списка и без
|
|
||||||
атрибутов (кроме class="red" у <mark>), весь текст экранируется заново. Всё, что
|
|
||||||
парсер не распознал как тег из списка, становится текстом или пропадает."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__(convert_charrefs=True)
|
|
||||||
self.out: list[str] = []
|
|
||||||
self.text: list[str] = []
|
|
||||||
self.stack: list[str] = []
|
|
||||||
self.drop_depth = 0
|
|
||||||
|
|
||||||
def _pop(self) -> str:
|
|
||||||
top = self.stack.pop()
|
|
||||||
last = self.out[-1] if self.out else ""
|
|
||||||
# Точное сравнение: startswith("<b") поймал бы и <br>.
|
|
||||||
if last == f"<{top}>" or last.startswith(f"<{top} "):
|
|
||||||
self.out.pop() # пустая пара (<p></p> от вложенных <div>) — выбрасываем
|
|
||||||
else:
|
|
||||||
self.out.append(f"</{top}>")
|
|
||||||
return top
|
|
||||||
|
|
||||||
def _close_to(self, tag: str) -> None:
|
|
||||||
while self.stack:
|
|
||||||
if self._pop() == tag:
|
|
||||||
return
|
|
||||||
|
|
||||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
||||||
if tag in _DROP_WITH_CONTENT:
|
|
||||||
self.drop_depth += 1
|
|
||||||
return
|
|
||||||
if self.drop_depth or tag not in _TAGS:
|
|
||||||
return
|
|
||||||
name = _TAGS[tag]
|
|
||||||
if name in _VOID:
|
|
||||||
self.out.append(f"<{name}>")
|
|
||||||
return
|
|
||||||
if name == "p" and "p" in self.stack:
|
|
||||||
# Абзац внутри абзаца (вложенные <div> из contenteditable) — закрываем прежний.
|
|
||||||
self._close_to("p")
|
|
||||||
opening = f"<{name}>"
|
|
||||||
if name == "mark":
|
|
||||||
classes = next((v or "" for k, v in attrs if k == "class"), "").split()
|
|
||||||
if "red" in classes:
|
|
||||||
opening = '<mark class="red">'
|
|
||||||
self.stack.append(name)
|
|
||||||
self.out.append(opening)
|
|
||||||
|
|
||||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
||||||
# <br/> и прочие самозакрытые: содержимого нет, так что drop-теги тут ни при чём.
|
|
||||||
if not self.drop_depth and _TAGS.get(tag) in _VOID:
|
|
||||||
self.out.append(f"<{_TAGS[tag]}>")
|
|
||||||
|
|
||||||
def handle_endtag(self, tag: str) -> None:
|
|
||||||
if tag in _DROP_WITH_CONTENT:
|
|
||||||
self.drop_depth = max(0, self.drop_depth - 1)
|
|
||||||
return
|
|
||||||
if self.drop_depth:
|
|
||||||
return
|
|
||||||
name = _TAGS.get(tag)
|
|
||||||
if name and name not in _VOID and name in self.stack:
|
|
||||||
self._close_to(name)
|
|
||||||
|
|
||||||
def handle_data(self, data: str) -> None:
|
|
||||||
if self.drop_depth:
|
|
||||||
return
|
|
||||||
self.text.append(data)
|
|
||||||
self.out.append(html.escape(data, quote=False))
|
|
||||||
|
|
||||||
def result(self) -> tuple[str, str]:
|
|
||||||
self.close()
|
|
||||||
while self.stack:
|
|
||||||
self._pop()
|
|
||||||
return "".join(self.out), "".join(self.text)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_body(raw: str) -> tuple[str, str]:
|
|
||||||
"""(очищенный HTML, видимый текст). Видимый текст нужен для проверки длины."""
|
|
||||||
parser = _Sanitizer()
|
|
||||||
parser.feed(raw)
|
|
||||||
return parser.result()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Проверки и время ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
# В SQLite моменты лежат наивными (UTC) — сравниваем с наивным же «сейчас».
|
|
||||||
return utcnow().replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _naive_utc(dt: datetime) -> datetime:
|
|
||||||
"""Момент из запроса → наивный UTC. Время без смещения считаем UTC."""
|
|
||||||
if dt.tzinfo is not None:
|
|
||||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
return dt.replace(second=0, microsecond=0)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_fields(
|
|
||||||
title: str, body_html: str, starts_at: datetime, ends_at: datetime
|
|
||||||
) -> tuple[str, str, datetime, datetime]:
|
|
||||||
title = title.strip()
|
|
||||||
if not title:
|
|
||||||
raise ValidationError("Заголовок не может быть пустым.")
|
|
||||||
if len(title) > TITLE_MAX:
|
|
||||||
raise ValidationError(f"Заголовок длиннее {TITLE_MAX} символов.")
|
|
||||||
clean_html, text = sanitize_body(body_html)
|
|
||||||
text = text.strip()
|
|
||||||
if not text:
|
|
||||||
raise ValidationError("Текст объявления не может быть пустым.")
|
|
||||||
if len(text) > TEXT_MAX:
|
|
||||||
raise ValidationError(f"Текст длиннее {TEXT_MAX} символов ({len(text)}).")
|
|
||||||
start, end = _naive_utc(starts_at), _naive_utc(ends_at)
|
|
||||||
if end <= start:
|
|
||||||
raise ValidationError("Конец показа должен быть позже начала.")
|
|
||||||
return title, clean_html, start, end
|
|
||||||
|
|
||||||
|
|
||||||
def status_of(a: Announcement, now: datetime | None = None) -> str:
|
|
||||||
now = now or _now()
|
|
||||||
if now < a.starts_at:
|
|
||||||
return "planned"
|
|
||||||
if now >= a.ends_at:
|
|
||||||
return "finished"
|
|
||||||
return "live"
|
|
||||||
|
|
||||||
|
|
||||||
def get(session: Session, announcement_id: int) -> Announcement:
|
|
||||||
a = session.get(Announcement, announcement_id)
|
|
||||||
if a is None:
|
|
||||||
raise NotFoundError("Объявление не найдено.")
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Админка ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def create(
|
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
body_html: str,
|
|
||||||
starts_at: datetime,
|
|
||||||
ends_at: datetime,
|
|
||||||
show_to_new_players: bool,
|
|
||||||
actor_id: int | None,
|
|
||||||
) -> Announcement:
|
|
||||||
title, body_html, start, end = _clean_fields(title, body_html, starts_at, ends_at)
|
|
||||||
if end <= _now():
|
|
||||||
raise ValidationError("Период показа уже закончился.")
|
|
||||||
a = Announcement(
|
|
||||||
title=title,
|
|
||||||
body_html=body_html,
|
|
||||||
starts_at=start,
|
|
||||||
ends_at=end,
|
|
||||||
show_to_new_players=show_to_new_players,
|
|
||||||
created_by=actor_id,
|
|
||||||
)
|
|
||||||
session.add(a)
|
|
||||||
session.flush()
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
def update(
|
|
||||||
session: Session,
|
|
||||||
announcement_id: int,
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
body_html: str,
|
|
||||||
starts_at: datetime,
|
|
||||||
ends_at: datetime,
|
|
||||||
show_to_new_players: bool,
|
|
||||||
reshow: bool,
|
|
||||||
) -> Announcement:
|
|
||||||
a = get(session, announcement_id)
|
|
||||||
a.title, a.body_html, a.starts_at, a.ends_at = _clean_fields(
|
|
||||||
title, body_html, starts_at, ends_at
|
|
||||||
)
|
|
||||||
a.show_to_new_players = show_to_new_players
|
|
||||||
if reshow:
|
|
||||||
# Новая версия: отметки о закрытии прежней больше не прячут объявление.
|
|
||||||
a.revision += 1
|
|
||||||
a.updated_at = utcnow()
|
|
||||||
session.add(a)
|
|
||||||
session.flush()
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
def stop(session: Session, announcement_id: int) -> Announcement:
|
|
||||||
"""«Снять с показа»: период заканчивается сейчас. Только у идущего объявления —
|
|
||||||
у запланированного конец раньше начала нарушил бы период; его просто удаляют."""
|
|
||||||
a = get(session, announcement_id)
|
|
||||||
now = _now()
|
|
||||||
if status_of(a, now) != "live" or now <= a.starts_at:
|
|
||||||
raise ValidationError("Снять с показа можно только идущее объявление.")
|
|
||||||
a.ends_at = now
|
|
||||||
a.updated_at = utcnow()
|
|
||||||
session.add(a)
|
|
||||||
session.flush()
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
def delete(session: Session, announcement_id: int) -> None:
|
|
||||||
session.delete(get(session, announcement_id))
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _audience_filter(a: Announcement):
|
|
||||||
"""Условия «игрок — адресат объявления» (для счётчиков и для показа)."""
|
|
||||||
conds = [User.role == "player", User.is_active.is_(True)] # type: ignore[union-attr]
|
|
||||||
if not a.show_to_new_players:
|
|
||||||
conds.append(User.created_at <= a.starts_at)
|
|
||||||
return conds
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_item(session: Session, a: Announcement, now: datetime) -> dict:
|
|
||||||
"""Объявление со статусом и счётчиком «закрыли N из M»: N — закрывшие текущую
|
|
||||||
версию, M — сколько активных игроков сейчас в адресатах."""
|
|
||||||
audience = _audience_filter(a)
|
|
||||||
audience_count = session.exec(select(func.count()).select_from(User).where(*audience)).one()
|
|
||||||
seen_count = session.exec(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(AnnouncementView)
|
|
||||||
.join(User, User.id == AnnouncementView.user_id)
|
|
||||||
.where(
|
|
||||||
AnnouncementView.announcement_id == a.id,
|
|
||||||
AnnouncementView.revision >= a.revision,
|
|
||||||
*audience,
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
return {"a": a, "status": status_of(a, now), "seen": seen_count, "audience": audience_count}
|
|
||||||
|
|
||||||
|
|
||||||
def admin_item(session: Session, announcement_id: int) -> dict:
|
|
||||||
return _admin_item(session, get(session, announcement_id), _now())
|
|
||||||
|
|
||||||
|
|
||||||
def list_admin(session: Session) -> list[dict]:
|
|
||||||
"""Все объявления, новые сверху. Объявлений единицы, поэтому счётчики — по паре
|
|
||||||
запросов на объявление."""
|
|
||||||
now = _now()
|
|
||||||
rows = session.exec(
|
|
||||||
select(Announcement).order_by(Announcement.starts_at.desc(), Announcement.id.desc()) # type: ignore[union-attr]
|
|
||||||
).all()
|
|
||||||
return [_admin_item(session, a, now) for a in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Игрок ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def pending_for_user(session: Session, user: User) -> list[tuple[Announcement, bool]]:
|
|
||||||
"""Что показать игроку сейчас — от старого к новому. Второй элемент — «обновлено»:
|
|
||||||
игрок закрывал прежнюю версию этого объявления."""
|
|
||||||
now = _now()
|
|
||||||
registered = user.created_at.replace(tzinfo=None) # только что созданный — aware UTC
|
|
||||||
rows = session.exec(
|
|
||||||
select(Announcement, AnnouncementView.revision)
|
|
||||||
.join(
|
|
||||||
AnnouncementView,
|
|
||||||
(AnnouncementView.announcement_id == Announcement.id)
|
|
||||||
& (AnnouncementView.user_id == user.id),
|
|
||||||
isouter=True,
|
|
||||||
)
|
|
||||||
.where(Announcement.starts_at <= now, Announcement.ends_at > now)
|
|
||||||
.order_by(Announcement.starts_at, Announcement.id)
|
|
||||||
).all()
|
|
||||||
result = []
|
|
||||||
for a, seen_revision in rows:
|
|
||||||
if seen_revision is not None and seen_revision >= a.revision:
|
|
||||||
continue
|
|
||||||
if not a.show_to_new_players and registered > a.starts_at:
|
|
||||||
continue
|
|
||||||
result.append((a, seen_revision is not None))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def acknowledge(session: Session, user_id: int, announcement_id: int, revision: int) -> None:
|
|
||||||
"""Игрок закрыл объявление в версии revision. Версию берём с клиента: если админ
|
|
||||||
выпустил новую, пока окно было открыто, игрок закрыл старую и новую ещё увидит."""
|
|
||||||
a = get(session, announcement_id)
|
|
||||||
revision = min(revision, a.revision)
|
|
||||||
view = session.get(AnnouncementView, (announcement_id, user_id))
|
|
||||||
if view is None:
|
|
||||||
session.add(
|
|
||||||
AnnouncementView(announcement_id=announcement_id, user_id=user_id, revision=revision)
|
|
||||||
)
|
|
||||||
elif revision > view.revision:
|
|
||||||
view.revision = revision
|
|
||||||
view.closed_at = utcnow()
|
|
||||||
session.add(view)
|
|
||||||
session.flush()
|
|
||||||
@@ -97,16 +97,6 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
|
|
||||||
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
|
|
||||||
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
|
|
||||||
group.nine_rounds_rule = enabled
|
|
||||||
session.add(group)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(group)
|
|
||||||
return group
|
|
||||||
|
|
||||||
|
|
||||||
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
|
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
|
||||||
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
||||||
current = session.exec(
|
current = session.exec(
|
||||||
|
|||||||
@@ -19,12 +19,9 @@ from app.core.errors import (
|
|||||||
from app.core.timeutil import app_today, iso_utc, utcnow
|
from app.core.timeutil import app_today, iso_utc, utcnow
|
||||||
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
|
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
|
||||||
from app.services import group_service
|
from app.services import group_service
|
||||||
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
|
|
||||||
|
|
||||||
MAX_MATCH_PLAYERS = 6
|
MAX_MATCH_PLAYERS = 6
|
||||||
LAST_STANDING = "last_standing"
|
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
|
||||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
|
|
||||||
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -45,13 +42,11 @@ class FinishInput:
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
faction_id: int | None = None # опц. смена фракции при завершении
|
faction_id: int | None = None # опц. смена фракции при завершении
|
||||||
objectives: int | None = None
|
|
||||||
worlds: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ParticipantInput:
|
class ParticipantInput:
|
||||||
"""Полный участник (для правки результатов завершённой партии)."""
|
"""Полный участник (для правки завершённой партии админом)."""
|
||||||
|
|
||||||
user_id: int
|
user_id: int
|
||||||
faction_id: int
|
faction_id: int
|
||||||
@@ -59,8 +54,6 @@ class ParticipantInput:
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool = False
|
was_random: bool = False
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
objectives: int | None = None
|
|
||||||
worlds: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def round_to_30(minutes: float) -> int:
|
def round_to_30(minutes: float) -> int:
|
||||||
@@ -161,43 +154,6 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int
|
|||||||
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
|
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
|
|
||||||
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
|
|
||||||
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
|
|
||||||
|
|
||||||
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
|
|
||||||
if end_round is None:
|
|
||||||
return
|
|
||||||
rmax = max_rounds(player_count, nine_rounds_rule)
|
|
||||||
if not 1 <= end_round <= rmax:
|
|
||||||
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
|
|
||||||
|
|
||||||
|
|
||||||
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
|
|
||||||
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
|
|
||||||
if not eliminated:
|
|
||||||
return worlds
|
|
||||||
if worlds:
|
|
||||||
raise ValidationError("У выбывшего игрока не может быть миров.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
|
|
||||||
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
|
|
||||||
|
|
||||||
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
|
|
||||||
сервер лишь не пропускает расхождение."""
|
|
||||||
alone = sum(1 for e in eliminated if not e) == 1
|
|
||||||
if alone and win_reason != LAST_STANDING:
|
|
||||||
raise ValidationError(
|
|
||||||
"Остался один невыбывший игрок — причина победы «последний выживший»."
|
|
||||||
)
|
|
||||||
if not alone and win_reason == LAST_STANDING:
|
|
||||||
raise ValidationError(
|
|
||||||
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||||
return {
|
return {
|
||||||
m.user_id
|
m.user_id
|
||||||
@@ -252,15 +208,12 @@ def create_match(
|
|||||||
)
|
)
|
||||||
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
group = group_service.get_group(session, group_id)
|
|
||||||
match = Match(
|
match = Match(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
status="in_progress",
|
status="in_progress",
|
||||||
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
||||||
started_at=now,
|
started_at=now,
|
||||||
player_count=len(roster),
|
player_count=len(roster),
|
||||||
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
|
|
||||||
nine_rounds_rule=group.nine_rounds_rule,
|
|
||||||
created_by=creator.id, # type: ignore[arg-type]
|
created_by=creator.id, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
session.add(match)
|
session.add(match)
|
||||||
@@ -289,7 +242,6 @@ def finish_match(
|
|||||||
*,
|
*,
|
||||||
finish: list[FinishInput],
|
finish: list[FinishInput],
|
||||||
win_reason: str,
|
win_reason: str,
|
||||||
end_round: int | None = None,
|
|
||||||
overall_comment: str | None = None,
|
overall_comment: str | None = None,
|
||||||
overall_comment_set: bool = False,
|
overall_comment_set: bool = False,
|
||||||
expected_version: str | None = None,
|
expected_version: str | None = None,
|
||||||
@@ -299,7 +251,6 @@ def finish_match(
|
|||||||
raise ConflictError("Партия уже завершена.")
|
raise ConflictError("Партия уже завершена.")
|
||||||
if win_reason not in WIN_REASONS:
|
if win_reason not in WIN_REASONS:
|
||||||
raise ValidationError("Укажите корректную причину победы.")
|
raise ValidationError("Укажите корректную причину победы.")
|
||||||
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
|
|
||||||
|
|
||||||
existing = {
|
existing = {
|
||||||
p.user_id: p
|
p.user_id: p
|
||||||
@@ -322,16 +273,12 @@ def finish_match(
|
|||||||
raise FactionNotAvailableError()
|
raise FactionNotAvailableError()
|
||||||
|
|
||||||
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
|
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
|
||||||
_check_last_standing(win_reason, [f.eliminated for f in finish])
|
|
||||||
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish}
|
|
||||||
|
|
||||||
for f in finish:
|
for f in finish:
|
||||||
p = existing[f.user_id]
|
p = existing[f.user_id]
|
||||||
p.place = places[f.user_id]
|
p.place = places[f.user_id]
|
||||||
p.eliminated = f.eliminated
|
p.eliminated = f.eliminated
|
||||||
p.comment = f.comment or None
|
p.comment = f.comment or None
|
||||||
p.objectives = f.objectives
|
|
||||||
p.worlds = worlds[f.user_id]
|
|
||||||
if f.faction_id is not None:
|
if f.faction_id is not None:
|
||||||
p.faction_id = f.faction_id
|
p.faction_id = f.faction_id
|
||||||
session.add(p)
|
session.add(p)
|
||||||
@@ -347,7 +294,6 @@ def finish_match(
|
|||||||
match.duration_minutes = round_to_30(elapsed_min)
|
match.duration_minutes = round_to_30(elapsed_min)
|
||||||
match.status = "finished"
|
match.status = "finished"
|
||||||
match.win_reason = win_reason
|
match.win_reason = win_reason
|
||||||
match.end_round = end_round
|
|
||||||
if overall_comment_set:
|
if overall_comment_set:
|
||||||
match.overall_comment = overall_comment or None
|
match.overall_comment = overall_comment or None
|
||||||
|
|
||||||
@@ -366,44 +312,22 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None
|
|||||||
return session.get(MatchFinishDraft, match_id)
|
return session.get(MatchFinishDraft, match_id)
|
||||||
|
|
||||||
|
|
||||||
def _draft_counts(value: object) -> dict[str, int]:
|
|
||||||
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValidationError("Некорректный черновик.")
|
|
||||||
out: dict[str, int] = {}
|
|
||||||
for k, v in value.items():
|
|
||||||
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
|
|
||||||
raise ValidationError("Некорректный черновик.")
|
|
||||||
out[str(k)] = v
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_draft(session: Session, match: Match, data: dict) -> dict:
|
def _validate_draft(session: Session, match: Match, data: dict) -> dict:
|
||||||
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
|
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
|
||||||
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
|
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
|
||||||
раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
|
раскладывает их постепенно, и промежуточное состояние может быть любым."""
|
||||||
причине не проверяется и правило «последнего выжившего»."""
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValidationError("Некорректный черновик.")
|
raise ValidationError("Некорректный черновик.")
|
||||||
blocks = data.get("blocks") or []
|
blocks = data.get("blocks") or []
|
||||||
eliminated = data.get("eliminated") or []
|
eliminated = data.get("eliminated") or []
|
||||||
comments = data.get("comments") or {}
|
comments = data.get("comments") or {}
|
||||||
win_reason = data.get("win_reason")
|
win_reason = data.get("win_reason")
|
||||||
end_round = data.get("end_round")
|
|
||||||
if not isinstance(blocks, list) or not isinstance(eliminated, list):
|
if not isinstance(blocks, list) or not isinstance(eliminated, list):
|
||||||
raise ValidationError("Некорректный черновик.")
|
raise ValidationError("Некорректный черновик.")
|
||||||
if not isinstance(comments, dict):
|
if not isinstance(comments, dict):
|
||||||
raise ValidationError("Некорректный черновик.")
|
raise ValidationError("Некорректный черновик.")
|
||||||
if win_reason is not None and win_reason not in WIN_REASONS:
|
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||||
raise ValidationError("Некорректная причина победы.")
|
raise ValidationError("Некорректная причина победы.")
|
||||||
if end_round is not None and (
|
|
||||||
isinstance(end_round, bool)
|
|
||||||
or not isinstance(end_round, int)
|
|
||||||
or not 1 <= end_round <= EXTENDED_ROUNDS
|
|
||||||
):
|
|
||||||
raise ValidationError("Некорректный черновик.")
|
|
||||||
objectives = _draft_counts(data.get("objectives") or {})
|
|
||||||
worlds = _draft_counts(data.get("worlds") or {})
|
|
||||||
|
|
||||||
participant_ids = {
|
participant_ids = {
|
||||||
p.user_id
|
p.user_id
|
||||||
@@ -430,9 +354,6 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict:
|
|||||||
"comments": {str(k): str(v) for k, v in comments.items()},
|
"comments": {str(k): str(v) for k, v in comments.items()},
|
||||||
"win_reason": win_reason,
|
"win_reason": win_reason,
|
||||||
"overall_comment": overall,
|
"overall_comment": overall,
|
||||||
"end_round": end_round,
|
|
||||||
"objectives": objectives,
|
|
||||||
"worlds": worlds,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -488,41 +409,35 @@ def update_match(
|
|||||||
overall_comment_set: bool = False,
|
overall_comment_set: bool = False,
|
||||||
win_reason: str | None = None,
|
win_reason: str | None = None,
|
||||||
win_reason_set: bool = False,
|
win_reason_set: bool = False,
|
||||||
end_round: int | None = None,
|
|
||||||
end_round_set: bool = False,
|
|
||||||
participants: list[ParticipantInput] | None = None,
|
participants: list[ParticipantInput] | None = None,
|
||||||
expected_version: str | None = None,
|
expected_version: str | None = None,
|
||||||
) -> Match:
|
) -> Match:
|
||||||
"""Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
|
"""Правка партии: состав с местами, дата, комментарий, причина победы.
|
||||||
|
|
||||||
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
|
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они
|
||||||
иначе они оседали бы в партии со статусом in_progress, которая остаётся
|
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не
|
||||||
в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
|
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished').
|
||||||
только status='finished'). Дату и общий комментарий править можно и по ходу
|
Дату и общий комментарий править можно и по ходу партии — двойственного состояния
|
||||||
партии — двойственного состояния они не создают."""
|
они не создают."""
|
||||||
results_touched = participants is not None or win_reason_set or end_round_set
|
results_touched = participants is not None or win_reason_set
|
||||||
if results_touched and match.status != "finished":
|
if results_touched and match.status != "finished":
|
||||||
raise ConflictError(
|
raise ConflictError(
|
||||||
"Результаты незавершённой партии нельзя править — сначала завершите её."
|
"Результаты незавершённой партии нельзя править — сначала завершите её."
|
||||||
)
|
)
|
||||||
assert_version(match, expected_version)
|
assert_version(match, expected_version)
|
||||||
if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
|
if played_at is not None:
|
||||||
raise ValidationError("Некорректная причина победы.")
|
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
|
||||||
|
|
||||||
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:
|
if participants is not None:
|
||||||
|
saved = session.exec(
|
||||||
|
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||||
|
).all()
|
||||||
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||||
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||||
_validate_roster_basics(
|
_validate_roster_basics(
|
||||||
@@ -536,25 +451,6 @@ def update_match(
|
|||||||
places = _resolve_finish_places(
|
places = _resolve_finish_places(
|
||||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||||
)
|
)
|
||||||
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
|
|
||||||
if participants is not None or win_reason_set:
|
|
||||||
flags = (
|
|
||||||
[p.eliminated for p in participants]
|
|
||||||
if participants is not None
|
|
||||||
else [p.eliminated for p in saved]
|
|
||||||
)
|
|
||||||
_check_last_standing(new_reason, flags)
|
|
||||||
|
|
||||||
if played_at is not None:
|
|
||||||
match.played_at = played_at
|
|
||||||
if overall_comment_set:
|
|
||||||
match.overall_comment = overall_comment or None
|
|
||||||
if win_reason_set:
|
|
||||||
match.win_reason = win_reason
|
|
||||||
if end_round_set:
|
|
||||||
match.end_round = end_round
|
|
||||||
|
|
||||||
if participants is not None:
|
|
||||||
for old in saved:
|
for old in saved:
|
||||||
session.delete(old)
|
session.delete(old)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -568,8 +464,6 @@ def update_match(
|
|||||||
eliminated=p.eliminated,
|
eliminated=p.eliminated,
|
||||||
was_random=p.was_random,
|
was_random=p.was_random,
|
||||||
comment=p.comment or None,
|
comment=p.comment or None,
|
||||||
objectives=p.objectives,
|
|
||||||
worlds=worlds[p.user_id],
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
match.player_count = len(participants)
|
match.player_count = len(participants)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.events import hub
|
from app.core.events import hub
|
||||||
from app.models import GroupMember, Match, User
|
from app.models import GroupMember, Match, MatchParticipant
|
||||||
|
|
||||||
|
|
||||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||||
@@ -17,28 +17,30 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ratings_changed(session: Session, notified: list[int]) -> None:
|
def match_participant_ids(session: Session, match_id: int) -> list[int]:
|
||||||
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
|
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
|
||||||
главную, историю и профили всех, кто играл после неё, и страницы других групп.
|
(история игр, публичный профиль, личная статистика) реально протухли."""
|
||||||
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
|
return list(
|
||||||
skip = set(notified)
|
session.exec(
|
||||||
ids = [
|
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
|
||||||
uid
|
|
||||||
for uid in session.exec(
|
|
||||||
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
|
||||||
).all()
|
).all()
|
||||||
if uid not in skip
|
)
|
||||||
]
|
|
||||||
hub.publish(ids, {"type": "ratings"})
|
|
||||||
|
|
||||||
|
|
||||||
def match_changed(session: Session, match: Match) -> None:
|
def match_changed(session: Session, match: Match) -> None:
|
||||||
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
|
"""Партия изменилась — уведомить всех участников её группы.
|
||||||
и остальных игроков (_ratings_changed)."""
|
|
||||||
members = _group_member_ids(session, match.group_id)
|
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
|
||||||
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
|
история и профили протухают только у игравших, поэтому их id едут в событии."""
|
||||||
if match.status == "finished":
|
hub.publish(
|
||||||
_ratings_changed(session, members)
|
_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]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||||
@@ -51,13 +53,22 @@ def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
|||||||
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
||||||
|
|
||||||
|
|
||||||
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
|
def match_removed(
|
||||||
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
|
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
|
||||||
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
|
) -> None:
|
||||||
members = _group_member_ids(session, group_id)
|
"""Партия удалена — уведомить участников группы (обновить списки).
|
||||||
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
|
|
||||||
if finished:
|
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
|
||||||
_ratings_changed(session, members)
|
ушли каскадом, и собрать их из базы невозможно."""
|
||||||
|
hub.publish(
|
||||||
|
_group_member_ids(session, group_id),
|
||||||
|
{
|
||||||
|
"type": "match",
|
||||||
|
"match_id": match_id,
|
||||||
|
"group_id": group_id,
|
||||||
|
"participant_ids": participant_ids or [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||||
@@ -76,13 +87,3 @@ def invitations_changed(user_id: int) -> None:
|
|||||||
def notifications_changed(user_id: int) -> None:
|
def notifications_changed(user_id: int) -> None:
|
||||||
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
|
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
|
||||||
hub.publish([user_id], {"type": "notifications"})
|
hub.publish([user_id], {"type": "notifications"})
|
||||||
|
|
||||||
|
|
||||||
def announcements_changed(session: Session) -> None:
|
|
||||||
"""Админ создал, поправил, снял или удалил объявление — всем активным игрокам:
|
|
||||||
открытые вкладки перезапросят, что показать. Объявление, чей период начнётся
|
|
||||||
позже, клиент подхватит сам — периодическим перезапросом."""
|
|
||||||
ids = session.exec(
|
|
||||||
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
|
||||||
).all()
|
|
||||||
hub.publish(ids, {"type": "announcements"})
|
|
||||||
|
|||||||
+36
-198
@@ -1,220 +1,58 @@
|
|||||||
"""Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23).
|
"""Метрика рейтинга. Вынесена отдельно — легко заменить.
|
||||||
|
|
||||||
Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md;
|
По умолчанию: League Points — нормированные очки за место с учётом размера стола
|
||||||
эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней).
|
и ничьих (competition ranking). За партию из N игроков:
|
||||||
|
points = (N - place - (tie_size - 1)/2) / (N - 1)
|
||||||
|
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
|
||||||
|
|
||||||
Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b:
|
Рейтинговый счёт игрока — сглаженное среднее (байесовское, формула IMDB):
|
||||||
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
score = (PRIOR_GAMES * PRIOR_MEAN + SUM(points)) / (PRIOR_GAMES + games) * 100
|
||||||
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
К реальным партиям «дописываются» PRIOR_GAMES виртуальных со средним PRIOR_MEAN:
|
||||||
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
на малой выборке рейтинг держится около 50 и лишь с опытом сходится к чистому
|
||||||
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
|
среднему — короткая удачная серия новичка не обгоняет стабильного ветерана.
|
||||||
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
|
|
||||||
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
|
|
||||||
признак партии подставляется типичным и не влияет на M.
|
|
||||||
|
|
||||||
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
|
||||||
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import date, datetime
|
|
||||||
from itertools import combinations
|
|
||||||
|
|
||||||
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
||||||
MIN_GAMES = 10
|
MIN_GAMES = 10
|
||||||
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
||||||
FACTION_MIN_GAMES = 2
|
FACTION_MIN_GAMES = 2
|
||||||
|
|
||||||
# ─── Правила игры ────────────────────────────────────────────────────────────
|
# Сглаживание рейтинга: сколько «виртуальных» партий и с каким средним добавляем.
|
||||||
|
PRIOR_GAMES = 10
|
||||||
|
PRIOR_MEAN = 0.5
|
||||||
|
|
||||||
# Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5).
|
# SQL-выражение сглаженного рейтинга поверх агрегата по строкам scored (s.points).
|
||||||
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
|
# При 0 партий SUM = NULL → score = NULL (рейтинга без игр нет).
|
||||||
WORLDS_PER_TILE = 2.2
|
SMOOTHED_SCORE_SQL = (
|
||||||
BASE_ROUNDS = 8
|
f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100"
|
||||||
# Домашнее правило группы: при 5–6 игроках играется 9 раундов.
|
)
|
||||||
EXTENDED_ROUNDS = 9
|
|
||||||
EXTENDED_MIN_PLAYERS = 5
|
|
||||||
|
|
||||||
# ─── Коэффициенты (документ, 4.10) ───────────────────────────────────────────
|
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count,
|
||||||
|
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
|
||||||
R0 = 1500.0 # стартовый рейтинг
|
MATCH_POINTS_SQL = (
|
||||||
D = 400.0 # масштаб: разница 400 пунктов — шансы 10:1
|
"CASE WHEN m.player_count > 1 "
|
||||||
K_MAX = 64.0 # K новичка (0 партий)
|
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
|
||||||
K_MIN = 16.0 # K опытного игрока
|
"/ (m.player_count - 1) "
|
||||||
K_GAMES = 20 # за сколько партий K линейно спускается от K_MAX к K_MIN
|
"ELSE 1.0 END"
|
||||||
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 max_rounds(player_count: int, nine_rounds_rule: bool) -> int:
|
def smoothed_score(points_sum: float, games: int) -> float | None:
|
||||||
"""Лимит раундов партии: 9 при хоумруле группы и 5+ игроках, иначе 8."""
|
"""Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python.
|
||||||
if nine_rounds_rule and player_count >= EXTENDED_MIN_PLAYERS:
|
|
||||||
return EXTENDED_ROUNDS
|
|
||||||
return BASE_ROUNDS
|
|
||||||
|
|
||||||
|
Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока).
|
||||||
def fair_worlds(player_count: int) -> float:
|
Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной
|
||||||
"""«Честная доля» миров на игрока — масштаб для разницы миров."""
|
формулы — источник расхождений, который потом ловится только глазами."""
|
||||||
return BOARD_TILES[player_count] * WORLDS_PER_TILE / player_count
|
if games <= 0:
|
||||||
|
return None # рейтинга без игр нет — как SUM(...) = NULL в SQL
|
||||||
|
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
|
||||||
def mu_tempo(rmax: int) -> float:
|
|
||||||
"""Типичный темп: партия закончилась в предпоследнем раунде."""
|
|
||||||
return 1.0 / (rmax - 1)
|
|
||||||
|
|
||||||
|
|
||||||
def expected(r_a: float, r_b: float) -> float:
|
|
||||||
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
|
|
||||||
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / D))
|
|
||||||
|
|
||||||
|
|
||||||
def k_factor(games: int) -> float:
|
|
||||||
left = max(0.0, 1.0 - games / K_GAMES)
|
|
||||||
return K_MIN + (K_MAX - K_MIN) * left
|
|
||||||
|
|
||||||
|
|
||||||
def table_weight(player_count: int) -> float:
|
|
||||||
return 1.0 + W_TABLE * (player_count - 2) / 4.0
|
|
||||||
|
|
||||||
|
|
||||||
def _clamp(x: float, lo: float, hi: float) -> float:
|
|
||||||
return max(lo, min(hi, x))
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Партия как вход расчёта ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RatedSeat:
|
|
||||||
user_id: int
|
|
||||||
place: int
|
|
||||||
faction_id: int = 0
|
|
||||||
eliminated: bool = False
|
|
||||||
objectives: int | None = None # маркеры целей на конец партии
|
|
||||||
worlds: int | None = None # дружественные миры на конец партии
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RatedMatch:
|
|
||||||
seats: tuple[RatedSeat, ...]
|
|
||||||
win_reason: str | None = None
|
|
||||||
end_round: int | None = None # раунд, в котором партия закончилась
|
|
||||||
nine_rounds_rule: bool = False # снимок настройки группы на момент партии
|
|
||||||
id: int = 0
|
|
||||||
group_id: int = 0
|
|
||||||
played_at: date | None = None
|
|
||||||
finished_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def pair_multiplier(m: RatedMatch, a: RatedSeat, b: RatedSeat) -> float:
|
|
||||||
"""Множитель отрыва пары; a — выше или наравне с b."""
|
|
||||||
n = len(m.seats)
|
|
||||||
tie = a.place == b.place
|
|
||||||
winner_pair = a.place == 1
|
|
||||||
add = 1.0
|
|
||||||
|
|
||||||
def diff(x: int, y: int) -> float:
|
|
||||||
return abs(x - y) if tie else max(0, x - y)
|
|
||||||
|
|
||||||
if winner_pair:
|
|
||||||
rmax = max_rounds(n, m.nine_rounds_rule)
|
|
||||||
mu = mu_tempo(rmax)
|
|
||||||
tempo = mu if m.end_round is None else (rmax - m.end_round) / (rmax - 1)
|
|
||||||
add += W_TEMPO * (tempo - mu)
|
|
||||||
if winner_pair and m.win_reason == "last_standing":
|
|
||||||
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
|
|
||||||
elif a.objectives is not None and b.objectives is not None:
|
|
||||||
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
|
|
||||||
else:
|
|
||||||
obj = MU_OBJ
|
|
||||||
add += W_OBJ * (obj - MU_OBJ)
|
|
||||||
if a.worlds is not None and b.worlds is not None:
|
|
||||||
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
|
|
||||||
else:
|
|
||||||
wor = MU_WORLDS
|
|
||||||
add += W_WORLDS * (wor - MU_WORLDS)
|
|
||||||
|
|
||||||
close = CLOSENESS.get(m.win_reason, 1.0) if winner_pair and m.win_reason else 1.0
|
|
||||||
return _clamp(add, M_MIN, M_MAX) * close
|
|
||||||
|
|
||||||
|
|
||||||
def rate_match(
|
|
||||||
ratings: dict[int, float], games: dict[int, int], m: RatedMatch
|
|
||||||
) -> tuple[dict[int, float], dict[int, float]]:
|
|
||||||
"""Изменения рейтинга участников и их результат относительно ожидания.
|
|
||||||
|
|
||||||
Возвращает (ΔR, perf): perf_i = Σ_j (S_ij − E_ij) / (N − 1) — насколько игрок
|
|
||||||
выступил выше ожидания, без множителя отрыва и K. Входные словари не мутирует."""
|
|
||||||
n = len(m.seats)
|
|
||||||
delta = {s.user_id: 0.0 for s in m.seats}
|
|
||||||
perf = {s.user_id: 0.0 for s in m.seats}
|
|
||||||
if n < 2:
|
|
||||||
return delta, perf
|
|
||||||
g = table_weight(n)
|
|
||||||
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
|
||||||
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
|
|
||||||
for a, b in combinations(m.seats, 2):
|
|
||||||
if a.eliminated and b.eliminated:
|
|
||||||
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
|
|
||||||
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
|
|
||||||
continue
|
|
||||||
if a.place > b.place:
|
|
||||||
a, b = b, a
|
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
|
||||||
e_ab = expected(r[a.user_id], r[b.user_id])
|
|
||||||
x = pair_multiplier(m, a, b) * (s_ab - e_ab)
|
|
||||||
delta[a.user_id] += k[a.user_id] * g / (n - 1) * x
|
|
||||||
delta[b.user_id] -= k[b.user_id] * g / (n - 1) * x
|
|
||||||
perf[a.user_id] += (s_ab - e_ab) / (n - 1)
|
|
||||||
perf[b.user_id] -= (s_ab - e_ab) / (n - 1)
|
|
||||||
return delta, perf
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Replay:
|
|
||||||
"""Итог проигрывания истории: рейтинги без округления и следы каждой партии."""
|
|
||||||
|
|
||||||
ratings: dict[int, float] = field(default_factory=dict)
|
|
||||||
games: dict[int, int] = field(default_factory=dict)
|
|
||||||
delta: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
|
|
||||||
perf: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def replay(matches: Iterable[RatedMatch]) -> Replay:
|
|
||||||
"""Проигрывает партии в переданном порядке (хронологию задаёт вызывающий)."""
|
|
||||||
out = Replay()
|
|
||||||
for m in matches:
|
|
||||||
delta, perf = rate_match(out.ratings, out.games, m)
|
|
||||||
for uid, dv in delta.items():
|
|
||||||
out.ratings[uid] = out.ratings.get(uid, R0) + dv
|
|
||||||
out.games[uid] = out.games.get(uid, 0) + 1
|
|
||||||
out.delta[(m.id, uid)] = dv
|
|
||||||
out.perf[(m.id, uid)] = perf[uid]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def leaderboard_sort_key(row: dict) -> tuple:
|
def leaderboard_sort_key(row: dict) -> tuple:
|
||||||
"""Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
|
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||||
|
|
||||||
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
|
|
||||||
всё равно упорядочены по настоящему значению."""
|
|
||||||
return (
|
return (
|
||||||
-(row["rating"] or 0.0),
|
-(row["score"] or 0.0),
|
||||||
-(row["win_rate"] or 0.0),
|
-(row["win_rate"] or 0.0),
|
||||||
-(row["games"] or 0),
|
-(row["games"] or 0),
|
||||||
(row["avg_place"] or 0.0),
|
(row["avg_place"] or 0.0),
|
||||||
|
|||||||
@@ -1,183 +1,105 @@
|
|||||||
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
|
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
|
||||||
|
|
||||||
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
|
|
||||||
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
|
|
||||||
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
|
|
||||||
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
|
|
||||||
винрейт и среднее место считает по партиям группы."""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, text
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User
|
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
|
||||||
from app.services import faction_service, group_service, membership_service, user_service
|
from app.services import faction_service, group_service, membership_service, user_service
|
||||||
from app.services.scoring import (
|
from app.services.scoring import (
|
||||||
FACTION_MIN_GAMES,
|
FACTION_MIN_GAMES,
|
||||||
|
MATCH_POINTS_SQL,
|
||||||
MIN_GAMES,
|
MIN_GAMES,
|
||||||
RatedMatch,
|
SMOOTHED_SCORE_SQL,
|
||||||
RatedSeat,
|
|
||||||
Replay,
|
|
||||||
leaderboard_sort_key,
|
leaderboard_sort_key,
|
||||||
replay,
|
smoothed_score,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Базовый блок: одна строка на участие с tie-aware очками.
|
||||||
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
|
# Учитываются только ЗАВЕРШЁННЫЕ партии (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'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _playable_match_ids():
|
def _round(value: Any, ndigits: int) -> float | None:
|
||||||
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
|
return None if value is None else round(float(value), ndigits)
|
||||||
|
|
||||||
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
|
|
||||||
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
|
|
||||||
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
|
|
||||||
return (
|
|
||||||
select(MatchParticipant.match_id)
|
|
||||||
.group_by(MatchParticipant.match_id)
|
|
||||||
.having(func.count() >= MIN_PARTICIPANTS)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_history(session: Session) -> list[RatedMatch]:
|
def _normalize(row: dict) -> dict:
|
||||||
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
|
||||||
|
avatar_url = None
|
||||||
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
|
if row.get("avatar_path"):
|
||||||
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
|
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
|
||||||
_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 {
|
return {
|
||||||
"games": games,
|
"user_id": row["user_id"],
|
||||||
"wins": wins,
|
"nickname": row["nickname"],
|
||||||
"win_rate": round(wins / games, 4),
|
"games": int(row["games"] or 0),
|
||||||
"avg_place": round(sum(s.place for _m, s in seats) / games, 2),
|
"wins": int(row["wins"] or 0),
|
||||||
"score": None if rating is None else round(rating),
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def leaderboard(
|
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
|
||||||
session: Session,
|
where = "WHERE s.group_id = :gid" if group_id is not None else ""
|
||||||
group_id: int | None = None,
|
sql = f"""
|
||||||
*,
|
{SCORED_CTE}
|
||||||
history: list[RatedMatch] | None = None,
|
SELECT u.id AS user_id, u.nickname AS nickname,
|
||||||
rep: Replay | None = None,
|
u.avatar_path AS avatar_path,
|
||||||
member_ids: set[int] | None = None,
|
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
|
||||||
) -> dict:
|
COUNT(*) AS games,
|
||||||
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и
|
SUM(s.is_win) AS wins,
|
||||||
group_stats их переиспользуют).
|
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]
|
||||||
|
|
||||||
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
|
|
||||||
считается по всем партиям. С 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)
|
|
||||||
|
|
||||||
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
||||||
for m in shown:
|
rows = _leaderboard_rows(session, group_id)
|
||||||
for s in m.seats:
|
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
|
||||||
if member_ids is None or s.user_id in member_ids:
|
provisional = [r for r in rows if r["games"] < MIN_GAMES]
|
||||||
by_user[s.user_id].append((m, s))
|
qualified.sort(key=leaderboard_sort_key)
|
||||||
users = (
|
provisional.sort(key=leaderboard_sort_key)
|
||||||
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
|
|
||||||
if by_user
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for uid, seats in by_user.items():
|
|
||||||
u = users[uid]
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"user_id": uid,
|
|
||||||
"nickname": u.nickname,
|
|
||||||
**_summary(seats, rep.ratings[uid]),
|
|
||||||
"rating": rep.ratings[uid], # только для сортировки
|
|
||||||
"rating_confirmed": _rating_confirmed(rep, uid),
|
|
||||||
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
qualified = sorted((r for r in rows if r["rating_confirmed"]), key=leaderboard_sort_key)
|
|
||||||
provisional = sorted((r for r in rows if not r["rating_confirmed"]), key=leaderboard_sort_key)
|
|
||||||
for i, r in enumerate(qualified, start=1):
|
for i, r in enumerate(qualified, start=1):
|
||||||
r["rank"] = i
|
r["rank"] = i
|
||||||
for r in provisional:
|
for r in provisional:
|
||||||
r["rank"] = None
|
r["rank"] = None
|
||||||
for r in rows:
|
|
||||||
del r["rating"]
|
|
||||||
return {
|
return {
|
||||||
"entries": qualified,
|
"entries": qualified,
|
||||||
"provisional": provisional,
|
"provisional": provisional,
|
||||||
@@ -185,38 +107,72 @@ def leaderboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _faction_breakdown(
|
def _user_scored_rows(session: Session, user_id: int) -> list[dict]:
|
||||||
session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int
|
"""Строки участия игрока со всеми полями, нужными витринам профиля.
|
||||||
) -> list[dict]:
|
|
||||||
by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма
|
||||||
for m, s in seats:
|
последних партий считаются из одного и того же набора строк. CTE джойнит участия
|
||||||
by_faction[s.faction_id].append((m, s))
|
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с
|
||||||
if not by_faction:
|
общим числом партий, а не с числом партий игрока."""
|
||||||
return []
|
sql = f"""
|
||||||
meta = {
|
{SCORED_CTE}
|
||||||
f.id: (f, code)
|
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at,
|
||||||
for f, code in session.exec(
|
s.place AS place, s.player_count AS player_count,
|
||||||
select(Faction, Expansion.code)
|
s.points AS points, s.is_win AS is_win,
|
||||||
.join(Expansion, Expansion.id == Faction.expansion_id)
|
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||||
.where(Faction.id.in_(list(by_faction)))
|
e.code AS expansion_code
|
||||||
).all()
|
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_from_rows(rows: list[dict]) -> list[dict]:
|
||||||
|
by_faction: dict[int, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
by_faction.setdefault(r["faction_id"], []).append(r)
|
||||||
out = []
|
out = []
|
||||||
for fid, group in by_faction.items():
|
for fid, group in by_faction.items():
|
||||||
faction, expansion_code = meta[fid]
|
games = len(group)
|
||||||
perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
|
wins = sum(int(r["is_win"]) for r in group)
|
||||||
|
meta = group[0]
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"faction_id": fid,
|
"faction_id": fid,
|
||||||
"code": faction.code,
|
"code": meta["code"],
|
||||||
"name_ru": faction.name_ru,
|
"name_ru": meta["name_ru"],
|
||||||
"expansion_code": expansion_code,
|
"expansion_code": meta["expansion_code"],
|
||||||
**{k: v for k, v in _summary(group, None).items() if k != "score"},
|
"games": games,
|
||||||
# Средний результат относительно ожидания (S − E) × 100: насколько игрок
|
"wins": wins,
|
||||||
# на фракции выступает выше рейтинговых шансов — без привязки к рейтингу.
|
"win_rate": _round(wins / games, 4),
|
||||||
"score": round(perf * 100, 1),
|
"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),
|
||||||
"name_ru_prepositional": faction_service.prepositional(
|
"name_ru_prepositional": faction_service.prepositional(
|
||||||
faction.code, faction.name_ru
|
meta["code"], meta["name_ru"]
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -224,15 +180,15 @@ def _faction_breakdown(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]:
|
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]:
|
||||||
recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True)
|
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"place": s.place,
|
"place": r["place"],
|
||||||
"player_count": len(m.seats),
|
"player_count": r["player_count"],
|
||||||
"played_at": str(m.played_at),
|
"played_at": str(r["played_at"]),
|
||||||
}
|
}
|
||||||
for m, s in recent[:limit]
|
for r in recent[:limit]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -252,91 +208,78 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
|
|
||||||
return _summary(_user_seats(history, user_id), rep.ratings.get(user_id))
|
|
||||||
|
|
||||||
|
|
||||||
def profile_stats(
|
def profile_stats(
|
||||||
session: Session,
|
session: Session,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
group_id: int | None = None,
|
||||||
*,
|
*,
|
||||||
history: list[RatedMatch] | None = None,
|
rows: list[dict] | None = None,
|
||||||
rep: Replay | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
|
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует)."""
|
||||||
переиспользует)."""
|
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id)
|
||||||
if history is None:
|
overall = _overall_from_rows(scoped)
|
||||||
history = load_history(session)
|
factions = _faction_breakdown_from_rows(scoped)
|
||||||
if rep is None:
|
|
||||||
rep = replay(history)
|
|
||||||
seats = _user_seats(history, user_id)
|
|
||||||
factions = _faction_breakdown(session, seats, rep, user_id)
|
|
||||||
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
||||||
best = max(qualified, key=lambda f: f["score"]) if qualified else None
|
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||||
worst = min(qualified, key=lambda f: f["score"]) if qualified else None
|
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||||
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
|
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
|
||||||
# рандомные раздачи.
|
# рандомные раздачи.
|
||||||
main = max(factions, key=lambda f: f["games"]) if factions else None
|
main = max(factions, key=lambda f: f["games"]) if factions else None
|
||||||
return {
|
return {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"overall": _summary(seats, rep.ratings.get(user_id)),
|
"overall": overall,
|
||||||
"factions": factions,
|
"factions": factions,
|
||||||
"best_faction": best,
|
"best_faction": best,
|
||||||
"worst_faction": worst,
|
"worst_faction": worst,
|
||||||
"favorite_faction": _favorite_faction(session, user_id),
|
"favorite_faction": _favorite_faction(session, user_id),
|
||||||
"main_faction": main,
|
"main_faction": main,
|
||||||
"recent_form": _recent_form(seats),
|
"recent_form": _recent_form_from_rows(scoped),
|
||||||
"min_games": MIN_GAMES,
|
"min_games": MIN_GAMES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def group_stats(session: Session, group_id: int) -> dict:
|
def group_stats(session: Session, group_id: int) -> dict:
|
||||||
history = load_history(session)
|
board = leaderboard(session, group_id=group_id)
|
||||||
rep = replay(history)
|
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
|
||||||
group_history = _for_group(history, group_id)
|
games_count, last_played = session.exec(
|
||||||
members = membership_service.list_members(session, group_id)
|
select(func.count(), func.max(Match.played_at)).where(
|
||||||
# Список игроков группы — только её текущий состав: удалённый из группы в нём не висит.
|
Match.group_id == group_id, Match.status == "finished"
|
||||||
board = leaderboard(
|
)
|
||||||
session,
|
).one()
|
||||||
group_id,
|
last_at = str(last_played) if last_played else None
|
||||||
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)
|
available_ids = group_service.available_faction_ids(session, group_id)
|
||||||
factions = sorted(
|
faction_meta = []
|
||||||
session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index]
|
sql = f"""
|
||||||
)
|
{SCORED_CTE}
|
||||||
faction_meta = [
|
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
|
||||||
"faction_id": f.id,
|
FROM factions f
|
||||||
"code": f.code,
|
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
|
||||||
"name_ru": f.name_ru,
|
GROUP BY f.id, f.code, f.name_ru
|
||||||
"games": games[f.id], # type: ignore[index]
|
ORDER BY games DESC, f.sort_order
|
||||||
"wins": wins[f.id], # type: ignore[index]
|
"""
|
||||||
"available": f.id in available_ids,
|
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
|
||||||
}
|
for r in rows:
|
||||||
for f in factions
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
|
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
|
||||||
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
|
|
||||||
played_ids = {e["user_id"] for e in board["entries"]} | {
|
played_ids = {e["user_id"] for e in board["entries"]} | {
|
||||||
e["user_id"] for e in board["provisional"]
|
e["user_id"] for e in board["provisional"]
|
||||||
}
|
}
|
||||||
inactive = []
|
inactive = []
|
||||||
for _m, u in members:
|
for _m, u in membership_service.list_members(session, group_id):
|
||||||
if u.id in played_ids:
|
if u.id in played_ids:
|
||||||
continue
|
continue
|
||||||
rating = rep.ratings.get(u.id) # type: ignore[arg-type]
|
|
||||||
inactive.append(
|
inactive.append(
|
||||||
{
|
{
|
||||||
"user_id": u.id,
|
"user_id": u.id,
|
||||||
@@ -345,8 +288,7 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
"wins": 0,
|
"wins": 0,
|
||||||
"win_rate": 0.0,
|
"win_rate": 0.0,
|
||||||
"avg_place": None,
|
"avg_place": None,
|
||||||
"score": None if rating is None else round(rating),
|
"score": None,
|
||||||
"rating_confirmed": _rating_confirmed(rep, u.id), # type: ignore[arg-type]
|
|
||||||
"rank": None,
|
"rank": None,
|
||||||
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||||
}
|
}
|
||||||
@@ -354,8 +296,8 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"group_id": group_id,
|
"group_id": group_id,
|
||||||
"total_matches": len(group_history),
|
"total_matches": games_count,
|
||||||
"last_match_at": str(last_played) if last_played else None,
|
"last_match_at": last_at,
|
||||||
"leaderboard": board["entries"],
|
"leaderboard": board["entries"],
|
||||||
"provisional": board["provisional"],
|
"provisional": board["provisional"],
|
||||||
"inactive": inactive,
|
"inactive": inactive,
|
||||||
@@ -374,8 +316,6 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
|
|||||||
"eliminated": p.eliminated,
|
"eliminated": p.eliminated,
|
||||||
"was_random": p.was_random,
|
"was_random": p.was_random,
|
||||||
"comment": p.comment,
|
"comment": p.comment,
|
||||||
"objectives": p.objectives,
|
|
||||||
"worlds": p.worlds,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -397,12 +337,8 @@ def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int,
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _match_items(
|
def _match_items(session: Session, matches) -> list[dict]:
|
||||||
session: Session, matches, rating_deltas: dict[int, float] | None = None
|
"""Элементы списка партий (общее для списка группы и истории игрока)."""
|
||||||
) -> list[dict]:
|
|
||||||
"""Элементы списка партий (общее для списка группы и истории игрока).
|
|
||||||
|
|
||||||
rating_deltas — {match_id: ΔR} владельца истории; у списка группы их нет."""
|
|
||||||
by_match = _participants_by_match(session, [m.id for m in matches])
|
by_match = _participants_by_match(session, [m.id for m in matches])
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
@@ -421,18 +357,18 @@ def _match_items(
|
|||||||
"overall_comment": m.overall_comment,
|
"overall_comment": m.overall_comment,
|
||||||
"created_by": m.created_by,
|
"created_by": m.created_by,
|
||||||
"participants": parts,
|
"participants": parts,
|
||||||
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||||
where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
|
total = session.exec(
|
||||||
total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
|
select(func.count()).select_from(Match).where(Match.group_id == group_id)
|
||||||
|
).one()
|
||||||
matches = session.exec(
|
matches = session.exec(
|
||||||
select(Match)
|
select(Match)
|
||||||
.where(*where)
|
.where(Match.group_id == group_id)
|
||||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -454,29 +390,27 @@ def user_match_list(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
||||||
|
|
||||||
У каждой партии — изменение общего рейтинга игрока за неё (rating_delta, один знак
|
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
|
||||||
после запятой). best_only — одна лучшая партия: наибольший прирост рейтинга
|
место и размер стола); при равных очках берём более свежую."""
|
||||||
(учитывает и соперников, и ход партии); при равенстве берём более свежую."""
|
|
||||||
history = load_history(session)
|
|
||||||
rep = replay(history)
|
|
||||||
mine = _user_seats(history, user_id)
|
|
||||||
# + 0.0 превращает −0.0 (мелкий минус, округлённый до нуля) в обычный ноль.
|
|
||||||
deltas = {m.id: round(rep.delta[(m.id, user_id)], 1) + 0.0 for m, _s in mine}
|
|
||||||
if best_only:
|
if best_only:
|
||||||
candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine]
|
sql = f"""
|
||||||
matches = [session.get(Match, max(candidates)[2])] if candidates else []
|
{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 []
|
||||||
return {
|
return {
|
||||||
"items": _match_items(session, matches, deltas),
|
"items": _match_items(session, matches),
|
||||||
"total": len(matches),
|
"total": len(matches),
|
||||||
"limit": 1,
|
"limit": 1,
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
where = (
|
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
|
||||||
MatchParticipant.user_id == user_id,
|
|
||||||
Match.status == "finished",
|
|
||||||
Match.id.in_(_playable_match_ids()),
|
|
||||||
)
|
|
||||||
total = session.exec(
|
total = session.exec(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Match)
|
.select_from(Match)
|
||||||
@@ -492,7 +426,7 @@ def user_match_list(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return {
|
return {
|
||||||
"items": _match_items(session, matches, deltas),
|
"items": _match_items(session, matches),
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
@@ -530,12 +464,10 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
||||||
# История грузится и проигрывается один раз: из неё и топ, и профиль, и блок активной
|
board = leaderboard(session, group_id=None)
|
||||||
# группы (там игры и победы по группе, рейтинг — общий).
|
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе.
|
||||||
history = load_history(session)
|
rows = _user_scored_rows(session, user_id)
|
||||||
rep = replay(history)
|
profile = profile_stats(session, user_id, group_id=None, rows=rows)
|
||||||
board = leaderboard(session, history=history, rep=rep)
|
|
||||||
profile = profile_stats(session, user_id, history=history, rep=rep)
|
|
||||||
active_group_brief = None
|
active_group_brief = None
|
||||||
if active_group_id is not None:
|
if active_group_id is not None:
|
||||||
group = session.get(Group, active_group_id)
|
group = session.get(Group, active_group_id)
|
||||||
@@ -543,7 +475,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
|
|||||||
active_group_brief = {
|
active_group_brief = {
|
||||||
"id": group.id,
|
"id": group.id,
|
||||||
"name": group.name,
|
"name": group.name,
|
||||||
**_overall(_for_group(history, active_group_id), rep, user_id),
|
**_overall_from_rows(_for_group(rows, active_group_id)),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"leaderboard": board["entries"][:leaderboard_limit],
|
"leaderboard": board["entries"][:leaderboard_limit],
|
||||||
|
|||||||
@@ -7,17 +7,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.auth.password import validate_new_password
|
|
||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import (
|
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||||
NicknameTakenError,
|
|
||||||
NotFoundError,
|
|
||||||
TelegramAlreadyLinkedError,
|
|
||||||
TelegramTakenError,
|
|
||||||
ValidationError,
|
|
||||||
)
|
|
||||||
from app.core.security import hash_password
|
|
||||||
from app.core.timeutil import utcnow
|
from app.core.timeutil import utcnow
|
||||||
from app.models import AuthIdentity, Faction, User
|
from app.models import AuthIdentity, Faction, User
|
||||||
|
|
||||||
@@ -140,70 +132,6 @@ def register_from_identity(
|
|||||||
return _create_from_identity(session, identity, nickname)
|
return _create_from_identity(session, identity, nickname)
|
||||||
|
|
||||||
|
|
||||||
def register_local(session: Session, nickname: str, password: str) -> User:
|
|
||||||
"""Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже."""
|
|
||||||
nickname = (nickname or "").strip()
|
|
||||||
if not nickname_format_ok(nickname):
|
|
||||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
|
||||||
if not nickname_available(session, nickname):
|
|
||||||
raise NicknameTakenError()
|
|
||||||
validate_new_password(password)
|
|
||||||
user = User(
|
|
||||||
nickname=nickname,
|
|
||||||
role="player",
|
|
||||||
auth_provider="local",
|
|
||||||
password_hash=hash_password(password),
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def set_password(session: Session, user: User, new_password: str) -> User:
|
|
||||||
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).
|
|
||||||
|
|
||||||
Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля
|
|
||||||
игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника).
|
|
||||||
Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver."""
|
|
||||||
validate_new_password(new_password)
|
|
||||||
user.password_hash = hash_password(new_password)
|
|
||||||
user.token_version = (user.token_version or 0) + 1
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def link_telegram(session: Session, user: User, identity: ExternalIdentity) -> User:
|
|
||||||
"""Привязать Telegram к существующему аккаунту; ник не меняется.
|
|
||||||
|
|
||||||
После привязки вход через Telegram попадает в этот аккаунт: find_by_identity находит
|
|
||||||
его по той же связке provider+external_id, что создаёт регистрация через Telegram."""
|
|
||||||
already = session.exec(
|
|
||||||
select(AuthIdentity).where(
|
|
||||||
AuthIdentity.user_id == user.id, AuthIdentity.provider == identity.provider
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if user.telegram_id is not None or already is not None:
|
|
||||||
raise TelegramAlreadyLinkedError()
|
|
||||||
taken_by_id = session.exec(select(User).where(User.telegram_id == identity.telegram_id)).first()
|
|
||||||
if find_by_identity(session, identity) is not None or taken_by_id is not None:
|
|
||||||
raise TelegramTakenError()
|
|
||||||
user.telegram_id = identity.telegram_id
|
|
||||||
session.add(user)
|
|
||||||
session.add(
|
|
||||||
AuthIdentity(
|
|
||||||
user_id=user.id, # type: ignore[arg-type]
|
|
||||||
provider=identity.provider,
|
|
||||||
external_id=identity.external_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||||
new_nickname = (new_nickname or "").strip()
|
new_nickname = (new_nickname or "").strip()
|
||||||
if not nickname_format_ok(new_nickname):
|
if not nickname_format_ok(new_nickname):
|
||||||
@@ -380,5 +308,5 @@ def public_profile(session: Session, user_id: int) -> dict:
|
|||||||
"nickname": user.nickname,
|
"nickname": user.nickname,
|
||||||
"bio": user.bio,
|
"bio": user.bio,
|
||||||
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
"stats": stats_service.profile_stats(session, user_id),
|
"stats": stats_service.profile_stats(session, user_id, group_id=None),
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-10
@@ -9,16 +9,8 @@ python -m app.bootstrap
|
|||||||
|
|
||||||
echo "[entrypoint] Запуск сервера…"
|
echo "[entrypoint] Запуск сервера…"
|
||||||
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
||||||
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS и кто реальный клиент.
|
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS.
|
||||||
#
|
|
||||||
# forwarded-allow-ips НЕ "*" (#58): при "*" uvicorn брал ЛЕВОЕ значение X-Forwarded-For,
|
|
||||||
# и клиент мог подставить произвольный IP (снятие throttle, порча аудита). Доверяем только
|
|
||||||
# апстримам из приватной сети compose (туннель к Caddy ходит на app:8000) и loopback
|
|
||||||
# (healthcheck) — тогда uvicorn сканирует XFF справа и берёт первый недоверенный адрес,
|
|
||||||
# т.е. реальный, добавленный Caddy. Портов на хост нет, снаружи к :8000 никто не ходит.
|
|
||||||
# Переопределяемо через FORWARDED_ALLOW_IPS, если сеть отличается.
|
|
||||||
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
||||||
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
||||||
FORWARDED_ALLOW_IPS="${FORWARDED_ALLOW_IPS:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
|
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
||||||
--proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10
|
--proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ from sqlalchemy.pool import StaticPool # noqa: E402
|
|||||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
||||||
|
|
||||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||||
from app.core.ratelimit import login_throttle # noqa: E402
|
|
||||||
from app.core.security import hash_password # noqa: E402
|
from app.core.security import hash_password # noqa: E402
|
||||||
from app.core.token_revocation import revoked_tokens # noqa: E402
|
|
||||||
from app.db.session import get_session # noqa: E402
|
from app.db.session import get_session # noqa: E402
|
||||||
from app.main import app # noqa: E402
|
from app.main import app # noqa: E402
|
||||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||||
@@ -50,8 +48,6 @@ def client(engine):
|
|||||||
yield s
|
yield s
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = _get_session
|
app.dependency_overrides[get_session] = _get_session
|
||||||
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
|
||||||
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.pool import StaticPool
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine, select
|
|
||||||
|
|
||||||
from app.models import Faction
|
|
||||||
from app.seed.reference_data import FACTIONS, seed_reference_data
|
|
||||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||||
|
|
||||||
|
|
||||||
@@ -209,37 +205,6 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
|
|||||||
assert ap["faction_name"] == "Орки WAAAGH"
|
assert ap["faction_name"] == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
|
|
||||||
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
|
|
||||||
имя, заданное админом (#72)."""
|
|
||||||
login(client, "Кто-то")
|
|
||||||
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
r = client.patch(
|
|
||||||
f"/api/admin/factions/{orks['id']}",
|
|
||||||
json={"name_ru": "Орки WAAAGH"},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
|
|
||||||
with Session(engine) as s:
|
|
||||||
seed_reference_data(s) # то же, что делает рестарт
|
|
||||||
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
|
|
||||||
|
|
||||||
|
|
||||||
def test_seeding_is_complete_and_idempotent():
|
|
||||||
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
|
|
||||||
engine = create_engine(
|
|
||||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
|
||||||
)
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
with Session(engine) as s:
|
|
||||||
seed_reference_data(s)
|
|
||||||
seed_reference_data(s)
|
|
||||||
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
|
|
||||||
assert names == {code: name for code, name, _exp, _order in FACTIONS}
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
||||||
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Ротация пароля администратора из .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,286 +0,0 @@
|
|||||||
"""Объявления администрации (#84): очистка HTML, права, период показа, «новые игроки»,
|
|
||||||
повторный показ с пометкой «обновлено», снятие с показа и удаление."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.models import AnnouncementView, User
|
|
||||||
from app.services import notify
|
|
||||||
from app.services.announcement_service import sanitize_body
|
|
||||||
from tests.conftest import csrf_headers, login
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(dt: datetime) -> str:
|
|
||||||
return dt.astimezone(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_login(client: TestClient, make_admin) -> None:
|
|
||||||
make_admin("admin", "secret123")
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/auth/login",
|
|
||||||
json={"username": "admin", "password": "secret123"},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
|
|
||||||
|
|
||||||
def _create(client: TestClient, **over) -> dict:
|
|
||||||
body = {
|
|
||||||
"title": "Турнир",
|
|
||||||
"body_html": "<p>Суббота, <b>11:00</b></p>",
|
|
||||||
"starts_at": _iso(_now() - timedelta(hours=1)),
|
|
||||||
"ends_at": _iso(_now() + timedelta(days=1)),
|
|
||||||
"show_to_new_players": True,
|
|
||||||
}
|
|
||||||
body.update(over)
|
|
||||||
r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client))
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _update(client: TestClient, ann: dict, **over) -> dict:
|
|
||||||
body = {
|
|
||||||
"title": ann["title"],
|
|
||||||
"body_html": ann["body_html"],
|
|
||||||
"starts_at": ann["starts_at"],
|
|
||||||
"ends_at": ann["ends_at"],
|
|
||||||
"show_to_new_players": ann["show_to_new_players"],
|
|
||||||
"reshow": False,
|
|
||||||
}
|
|
||||||
body.update(over)
|
|
||||||
r = client.put(
|
|
||||||
f"/api/admin/announcements/{ann['id']}", json=body, headers=csrf_headers(client)
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _pending(client: TestClient) -> list[dict]:
|
|
||||||
r = client.get("/api/announcements/pending")
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _ack(client: TestClient, ann_id: int, revision: int):
|
|
||||||
return client.post(
|
|
||||||
f"/api/announcements/{ann_id}/ack",
|
|
||||||
json={"revision": revision},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_item(client: TestClient, ann_id: int) -> dict:
|
|
||||||
return next(a for a in client.get("/api/admin/announcements").json() if a["id"] == ann_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Очистка HTML ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_sanitize_keeps_allowed_and_strips_everything_else():
|
|
||||||
raw = (
|
|
||||||
'<div>Привет <strong onclick="steal()">мир</strong></div>'
|
|
||||||
'<p style="color:red"><i>курсив</i> <mark class="red big">красный</mark> '
|
|
||||||
'<mark class="gold" onmouseover="x()">золотой</mark></p>'
|
|
||||||
"<script>alert(1)</script><style>p{display:none}</style>"
|
|
||||||
'<img src=x onerror="alert(1)"><a href="javascript:alert(1)">ссылка</a>'
|
|
||||||
"<svg><text>svg-текст</text></svg><b>"
|
|
||||||
)
|
|
||||||
html, text = sanitize_body(raw)
|
|
||||||
assert html == (
|
|
||||||
"<p>Привет <b>мир</b></p>"
|
|
||||||
'<p><em>курсив</em> <mark class="red">красный</mark> <mark>золотой</mark></p>'
|
|
||||||
"ссылка<b>"
|
|
||||||
)
|
|
||||||
# Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает.
|
|
||||||
assert text == "Привет миркурсив красный золотойссылка<b>"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_fixes_nesting_and_keeps_line_breaks():
|
|
||||||
html, _ = sanitize_body("<div><div>один</div><div>два<br></div></div><b>жирный<br/>хвост")
|
|
||||||
# Вложенные <div> из contenteditable → плоские абзацы без пустых <p></p>;
|
|
||||||
# <br> внутри <b> не путается с самим <b>; незакрытое закрывается.
|
|
||||||
assert html == "<p>один</p><p>два<br></p><b>жирный<br>хвост</b>"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Права ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_admin_endpoints_are_closed_to_players(client: TestClient):
|
|
||||||
assert client.get("/api/admin/announcements").status_code == 401
|
|
||||||
login(client, "Игрок")
|
|
||||||
assert client.get("/api/admin/announcements").status_code == 401
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/announcements",
|
|
||||||
json={
|
|
||||||
"title": "x",
|
|
||||||
"body_html": "y",
|
|
||||||
"starts_at": _iso(_now()),
|
|
||||||
"ends_at": _iso(_now() + timedelta(days=1)),
|
|
||||||
},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_pending_requires_player_session(client: TestClient):
|
|
||||||
assert client.get("/api/announcements/pending").status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Показ ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_created_announcement_is_shown_once(client: TestClient, make_admin, monkeypatch):
|
|
||||||
events: list[dict] = []
|
|
||||||
monkeypatch.setattr(notify.hub, "publish", lambda ids, ev: events.append(ev))
|
|
||||||
login(client, "Игрок")
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
|
|
||||||
ann = _create(client, body_html="<p>Сбор <b>в 11:00</b></p><script>alert(1)</script>")
|
|
||||||
assert ann["body_html"] == "<p>Сбор <b>в 11:00</b></p>"
|
|
||||||
assert ann["status"] == "live"
|
|
||||||
assert (ann["seen_count"], ann["audience_count"]) == (0, 1)
|
|
||||||
assert {"type": "announcements"} in events
|
|
||||||
|
|
||||||
items = _pending(client)
|
|
||||||
assert [(a["id"], a["revision"], a["updated"]) for a in items] == [(ann["id"], 1, False)]
|
|
||||||
assert _ack(client, ann["id"], 1).status_code == 200
|
|
||||||
assert _pending(client) == []
|
|
||||||
assert _admin_item(client, ann["id"])["seen_count"] == 1
|
|
||||||
|
|
||||||
logs = client.get("/api/admin/audit-logs?entity_type=announcement").json()["items"]
|
|
||||||
assert [(l["action"], l["entity_id"]) for l in logs] == [("create", ann["id"])]
|
|
||||||
|
|
||||||
|
|
||||||
def test_pending_respects_period_and_order(client: TestClient, make_admin):
|
|
||||||
login(client, "Игрок")
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
later = _create(client, title="Позже", starts_at=_iso(_now() - timedelta(minutes=30)))
|
|
||||||
earlier = _create(client, title="Раньше", starts_at=_iso(_now() - timedelta(hours=3)))
|
|
||||||
planned = _create(
|
|
||||||
client,
|
|
||||||
title="Завтра",
|
|
||||||
starts_at=_iso(_now() + timedelta(days=1)),
|
|
||||||
ends_at=_iso(_now() + timedelta(days=2)),
|
|
||||||
)
|
|
||||||
assert planned["status"] == "planned"
|
|
||||||
# Пересекающиеся периоды — от старого к новому; запланированного пока нет.
|
|
||||||
assert [a["title"] for a in _pending(client)] == ["Раньше", "Позже"]
|
|
||||||
assert earlier["id"] != later["id"]
|
|
||||||
|
|
||||||
# Период, который уже закончился, создать нельзя.
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/announcements",
|
|
||||||
json={
|
|
||||||
"title": "Прошлое",
|
|
||||||
"body_html": "текст",
|
|
||||||
"starts_at": _iso(_now() - timedelta(days=2)),
|
|
||||||
"ends_at": _iso(_now() - timedelta(days=1)),
|
|
||||||
},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_hidden_from_players_registered_after_start(client: TestClient, make_admin, engine):
|
|
||||||
login(client, "Старожил")
|
|
||||||
with Session(engine) as s:
|
|
||||||
old = s.exec(select(User).where(User.nickname == "Старожил")).one()
|
|
||||||
old.created_at = datetime.now(timezone.utc) - timedelta(days=2)
|
|
||||||
s.add(old)
|
|
||||||
s.commit()
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
ann = _create(client, show_to_new_players=False)
|
|
||||||
assert ann["audience_count"] == 1
|
|
||||||
|
|
||||||
assert [a["id"] for a in _pending(client)] == [ann["id"]]
|
|
||||||
login(client, "Новичок") # зарегистрирован уже после начала показа
|
|
||||||
assert _pending(client) == []
|
|
||||||
# Адресаты — по-прежнему только старожил.
|
|
||||||
assert _admin_item(client, ann["id"])["audience_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_reshow_brings_announcement_back_marked_updated(client: TestClient, make_admin):
|
|
||||||
login(client, "Игрок")
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
ann = _create(client)
|
|
||||||
assert _ack(client, ann["id"], 1).status_code == 200
|
|
||||||
|
|
||||||
# Правка без «показать заново» — закрывшие её не видят.
|
|
||||||
ann = _update(client, ann, body_html="<p>Сбор в 12:00</p>")
|
|
||||||
assert ann["revision"] == 1
|
|
||||||
assert _pending(client) == []
|
|
||||||
|
|
||||||
ann = _update(client, ann, body_html="<p>Сбор в 13:00</p>", reshow=True)
|
|
||||||
assert ann["revision"] == 2
|
|
||||||
assert ann["seen_count"] == 0 # закрывших новую версию ещё нет
|
|
||||||
items = _pending(client)
|
|
||||||
assert [(a["revision"], a["updated"], a["body_html"]) for a in items] == [
|
|
||||||
(2, True, "<p>Сбор в 13:00</p>")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью.
|
|
||||||
ann = _update(client, ann, reshow=True)
|
|
||||||
assert _ack(client, ann["id"], 2).status_code == 200
|
|
||||||
assert [(a["revision"], a["updated"]) for a in _pending(client)] == [(3, True)]
|
|
||||||
assert _ack(client, ann["id"], 3).status_code == 200
|
|
||||||
assert _pending(client) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_and_delete(client: TestClient, make_admin, engine):
|
|
||||||
login(client, "Игрок")
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
live = _create(client)
|
|
||||||
planned = _create(
|
|
||||||
client,
|
|
||||||
starts_at=_iso(_now() + timedelta(days=1)),
|
|
||||||
ends_at=_iso(_now() + timedelta(days=2)),
|
|
||||||
)
|
|
||||||
|
|
||||||
r = client.post(f"/api/admin/announcements/{planned['id']}/stop", headers=csrf_headers(client))
|
|
||||||
assert r.status_code == 422 # снять можно только идущее
|
|
||||||
|
|
||||||
r = client.post(f"/api/admin/announcements/{live['id']}/stop", headers=csrf_headers(client))
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
assert r.json()["status"] == "finished"
|
|
||||||
assert _pending(client) == []
|
|
||||||
|
|
||||||
assert _ack(client, live["id"], 1).status_code == 200 # закрыть можно и снятое
|
|
||||||
r = client.delete(f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client))
|
|
||||||
assert r.status_code == 200
|
|
||||||
with Session(engine) as s:
|
|
||||||
assert s.exec(select(AnnouncementView)).all() == [] # отметки ушли каскадом
|
|
||||||
assert client.delete(
|
|
||||||
f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client)
|
|
||||||
).status_code == 404
|
|
||||||
assert _ack(client, live["id"], 1).status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("over", "message_part"),
|
|
||||||
[
|
|
||||||
({"title": " "}, "Заголовок"),
|
|
||||||
({"title": "Я" * 61}, "Заголовок"),
|
|
||||||
({"body_html": "<p> </p><script>текст</script>"}, "пустым"),
|
|
||||||
({"body_html": "<p>" + "а" * 601 + "</p>"}, "600"),
|
|
||||||
({"ends_at": _iso(_now() - timedelta(hours=2))}, "позже начала"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_validation(client: TestClient, make_admin, over: dict, message_part: str):
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
body = {
|
|
||||||
"title": "Заголовок",
|
|
||||||
"body_html": "<p>текст</p>",
|
|
||||||
"starts_at": _iso(_now() - timedelta(hours=1)),
|
|
||||||
"ends_at": _iso(_now() + timedelta(days=1)),
|
|
||||||
}
|
|
||||||
body.update(over)
|
|
||||||
r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client))
|
|
||||||
assert r.status_code == 422, r.text
|
|
||||||
err = r.json()["error"]
|
|
||||||
assert err["code"] == "VALIDATION_ERROR"
|
|
||||||
assert message_part in err["message"]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.core import config
|
|
||||||
from app.main import create_app
|
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_open_in_development(client: TestClient):
|
|
||||||
# Тесты идут в development (conftest) — схема доступна: нужна для `npm run gen:api`.
|
|
||||||
assert client.get("/api/openapi.json").status_code == 200
|
|
||||||
assert client.get("/api/docs").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_closed_in_production(monkeypatch):
|
|
||||||
monkeypatch.setattr(config.settings, "app_env", "production")
|
|
||||||
prod_app = create_app()
|
|
||||||
c = TestClient(prod_app)
|
|
||||||
assert c.get("/api/openapi.json").status_code == 404
|
|
||||||
assert c.get("/api/docs").status_code == 404
|
|
||||||
assert c.get("/api/redoc").status_code == 404
|
|
||||||
@@ -10,9 +10,8 @@ from fastapi.testclient import TestClient
|
|||||||
from tests.conftest import csrf_headers
|
from tests.conftest import csrf_headers
|
||||||
|
|
||||||
|
|
||||||
def test_auth_config_dev_has_all_methods(client: TestClient):
|
def test_auth_config_dev_has_both_methods(client: TestClient):
|
||||||
cfg = client.get("/api/auth/config").json()
|
cfg = client.get("/api/auth/config").json()
|
||||||
assert "password" in cfg["methods"]
|
|
||||||
assert "telegram" in cfg["methods"]
|
assert "telegram" in cfg["methods"]
|
||||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||||
|
|
||||||
@@ -22,13 +21,15 @@ def test_enabled_methods_by_env(monkeypatch):
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "app_env", "development")
|
monkeypatch.setattr(settings, "app_env", "development")
|
||||||
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
assert set(enabled_methods()) == {"telegram", "stub"}
|
||||||
|
monkeypatch.setattr(settings, "app_env", "test")
|
||||||
|
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
|
||||||
monkeypatch.setattr(settings, "app_env", "production")
|
monkeypatch.setattr(settings, "app_env", "production")
|
||||||
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
assert enabled_methods() == ["telegram"] # prod → только Telegram
|
||||||
|
|
||||||
|
|
||||||
def test_env_flags_and_db_path(monkeypatch):
|
def test_env_flags_and_db_path(monkeypatch):
|
||||||
"""dev → файл дева; prod → том /data."""
|
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
|
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
|
||||||
@@ -36,6 +37,8 @@ def test_env_flags_and_db_path(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(settings, "app_env", "development")
|
monkeypatch.setattr(settings, "app_env", "development")
|
||||||
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
|
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
|
||||||
|
monkeypatch.setattr(settings, "app_env", "test")
|
||||||
|
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
|
||||||
monkeypatch.setattr(settings, "app_env", "production")
|
monkeypatch.setattr(settings, "app_env", "production")
|
||||||
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
|
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
|
|
||||||
стартует с дефолтными секретами (#59, F4, #69)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from app.core import config
|
|
||||||
|
|
||||||
_STRONG_SECRET = "k" * 40
|
|
||||||
_STRONG_ADMIN_PW = "a-strong-admin-password"
|
|
||||||
|
|
||||||
|
|
||||||
def test_production_rejects_default_secret_key():
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
config.Settings(
|
|
||||||
app_env="production",
|
|
||||||
secret_key=config._DEFAULT_SECRET_KEY,
|
|
||||||
admin_password=_STRONG_ADMIN_PW,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_production_rejects_short_secret_key():
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
config.Settings(
|
|
||||||
app_env="production",
|
|
||||||
secret_key="too-short",
|
|
||||||
admin_password=_STRONG_ADMIN_PW,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_production_rejects_default_admin_password():
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
config.Settings(
|
|
||||||
app_env="production",
|
|
||||||
secret_key=_STRONG_SECRET,
|
|
||||||
admin_bootstrap_enabled=True,
|
|
||||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_production_accepts_strong_secrets():
|
|
||||||
s = config.Settings(
|
|
||||||
app_env="production",
|
|
||||||
secret_key=_STRONG_SECRET,
|
|
||||||
admin_password=_STRONG_ADMIN_PW,
|
|
||||||
)
|
|
||||||
assert s.is_production
|
|
||||||
|
|
||||||
|
|
||||||
def test_production_skips_admin_check_when_bootstrap_disabled():
|
|
||||||
# Админ управляется вручную (bootstrap off) — дефолтный ADMIN_PASSWORD не блокирует старт.
|
|
||||||
s = config.Settings(
|
|
||||||
app_env="production",
|
|
||||||
secret_key=_STRONG_SECRET,
|
|
||||||
admin_bootstrap_enabled=False,
|
|
||||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
|
||||||
)
|
|
||||||
assert s.is_production
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
|
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
|
||||||
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -17,29 +16,20 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
|
|||||||
return published
|
return published
|
||||||
|
|
||||||
|
|
||||||
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
|
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
|
||||||
return {uid for ids, e in published if e.get("type") == kind for uid in ids}
|
return [e for _ids, e in published if e.get("type") == "match"]
|
||||||
|
|
||||||
|
|
||||||
def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
|
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||||
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
|
|
||||||
me = login(client, "Хост")
|
me = login(client, "Хост")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
def group(name: str) -> int:
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
return client.post(
|
).json()["id"]
|
||||||
"/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")
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
outsider = add_group_member(engine, other_gid, "Чужой")
|
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
||||||
|
p3 = add_group_member(engine, gid, "Зритель")
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
return me, gid, p2, outsider, fids
|
|
||||||
|
|
||||||
|
|
||||||
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
|
|
||||||
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
started = start_match(
|
started = start_match(
|
||||||
@@ -47,31 +37,36 @@ def test_finished_match_reaches_players_outside_group(client: TestClient, engine
|
|||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
)
|
)
|
||||||
assert started.status_code == 200, started.text
|
assert started.status_code == 200, started.text
|
||||||
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
|
mid = started.json()["id"]
|
||||||
assert _recipients(published, "match") == {me["id"], p2}
|
|
||||||
assert _recipients(published, "ratings") == set()
|
ev = _match_events(published)[-1]
|
||||||
|
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
|
||||||
|
assert p3 not in ev["participant_ids"]
|
||||||
|
|
||||||
published.clear()
|
published.clear()
|
||||||
fin = finish_match(
|
fin = finish_match(
|
||||||
client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||||
)
|
)
|
||||||
assert fin.status_code == 200, fin.text
|
assert fin.status_code == 200, fin.text
|
||||||
assert _recipients(published, "match") == {me["id"], p2}
|
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||||
ratings = _recipients(published, "ratings")
|
|
||||||
assert outsider in ratings
|
|
||||||
assert not ratings & {me["id"], p2} # группа уже получила подробное событие
|
|
||||||
|
|
||||||
|
|
||||||
def test_deleting_finished_match_reaches_players_outside_group(
|
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||||
client: TestClient, engine, monkeypatch
|
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
|
||||||
):
|
|
||||||
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
|
Если собирать их после удаления, список всегда окажется пустым, и клиент не
|
||||||
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
обновит историю тем, кто в этой партии играл."""
|
||||||
|
me = login(client, "Хост")
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
mid = start_match(
|
mid = start_match(
|
||||||
client, gid,
|
client, gid,
|
||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
).json()["id"]
|
).json()["id"]
|
||||||
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
version = client.get(f"/api/matches/{mid}").json()["version"]
|
version = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
@@ -79,5 +74,4 @@ def test_deleting_finished_match_reaches_players_outside_group(
|
|||||||
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert _recipients(published, "match") == {me["id"], p2}
|
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||||
assert outsider in _recipients(published, "ratings")
|
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
"""Список игроков группы — только текущий состав (#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)]
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.models import User
|
|
||||||
from tests.conftest import csrf_headers
|
|
||||||
from tests.test_auth import _telegram_payload
|
|
||||||
|
|
||||||
PASSWORD = "correct-horse"
|
|
||||||
|
|
||||||
|
|
||||||
def _register(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD):
|
|
||||||
return client.post(
|
|
||||||
"/api/auth/register",
|
|
||||||
json={"nickname": nickname, "password": password},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _login(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD):
|
|
||||||
return client.post(
|
|
||||||
"/api/auth/login",
|
|
||||||
json={"nickname": nickname, "password": password},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Регистрация ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_register_opens_session(client: TestClient, engine):
|
|
||||||
r = _register(client)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
me = r.json()
|
|
||||||
assert me["nickname"] == "Игрок"
|
|
||||||
assert me["auth_provider"] == "local"
|
|
||||||
assert me["has_password"] is True
|
|
||||||
assert client.cookies.get("fs_session")
|
|
||||||
|
|
||||||
assert client.get("/api/users/me").json()["id"] == me["id"]
|
|
||||||
with Session(engine) as s:
|
|
||||||
user = s.get(User, me["id"])
|
|
||||||
assert user.password_hash and PASSWORD not in user.password_hash
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_taken_nickname(client: TestClient):
|
|
||||||
assert _register(client).status_code == 200
|
|
||||||
client.cookies.clear()
|
|
||||||
r = _register(client, password="another-pass")
|
|
||||||
assert r.status_code == 409
|
|
||||||
assert r.json()["error"]["code"] == "NICKNAME_TAKEN"
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_rejects_bad_passwords(client: TestClient):
|
|
||||||
for bad in ["short", " ", "я" * 37]: # короткий, пробелы, 74 байта UTF-8
|
|
||||||
r = _register(client, password=bad)
|
|
||||||
assert r.status_code == 422, (bad, r.text)
|
|
||||||
assert _register(client, password="x" * 129).status_code == 422 # предел схемы
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_rejects_bad_nickname(client: TestClient):
|
|
||||||
assert _register(client, nickname="x").status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_is_throttled_per_ip(client: TestClient, monkeypatch):
|
|
||||||
"""Спам регистраций с одного IP упирается в лимит (#62)."""
|
|
||||||
import app.core.ratelimit as ratelimit
|
|
||||||
from app.auth.password import _REGISTER_IP_LIMIT
|
|
||||||
|
|
||||||
now = [4000.0]
|
|
||||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
|
||||||
|
|
||||||
for i in range(_REGISTER_IP_LIMIT):
|
|
||||||
client.cookies.clear()
|
|
||||||
assert _register(client, nickname=f"Ник{i}").status_code == 200
|
|
||||||
client.cookies.clear()
|
|
||||||
blocked = _register(client, nickname="Лишний")
|
|
||||||
assert blocked.status_code == 429
|
|
||||||
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
|
|
||||||
|
|
||||||
now[0] += 15 * 60 # окно истекло
|
|
||||||
client.cookies.clear()
|
|
||||||
assert _register(client, nickname="ПослеОкна").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Вход ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_login_after_logout(client: TestClient):
|
|
||||||
uid = _register(client).json()["id"]
|
|
||||||
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
|
||||||
client.cookies.clear()
|
|
||||||
|
|
||||||
r = _login(client)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
assert r.json()["id"] == uid
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_password_and_unknown_login_look_the_same(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
client.cookies.clear()
|
|
||||||
|
|
||||||
wrong = _login(client, password="wrong-password")
|
|
||||||
unknown = _login(client, nickname="Никто")
|
|
||||||
assert wrong.status_code == unknown.status_code == 401
|
|
||||||
assert wrong.json() == unknown.json()
|
|
||||||
assert wrong.json()["error"]["code"] == "INVALID_CREDENTIALS"
|
|
||||||
assert "fs_session" not in client.cookies
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_credentials_do_not_open_player_session(client: TestClient, make_admin):
|
|
||||||
make_admin("boss", "secret123")
|
|
||||||
r = _login(client, nickname="boss", password="secret123")
|
|
||||||
assert r.status_code == 401
|
|
||||||
assert "fs_session" not in client.cookies
|
|
||||||
|
|
||||||
|
|
||||||
def test_player_password_does_not_open_admin_session(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
client.cookies.clear()
|
|
||||||
r = client.post("/api/admin/auth/login", json={"username": "Игрок", "password": PASSWORD})
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_account_without_password_cannot_log_in(client: TestClient, engine):
|
|
||||||
with Session(engine) as s:
|
|
||||||
s.add(User(nickname="Телеграмщик", role="player", auth_provider="telegram"))
|
|
||||||
s.commit()
|
|
||||||
r = _login(client, nickname="Телеграмщик", password="anything-at-all")
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_account_cannot_log_in(client: TestClient, engine):
|
|
||||||
uid = _register(client).json()["id"]
|
|
||||||
client.cookies.clear()
|
|
||||||
with Session(engine) as s:
|
|
||||||
user = s.get(User, uid)
|
|
||||||
user.is_active = False
|
|
||||||
s.add(user)
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
r = _login(client)
|
|
||||||
assert r.status_code == 403
|
|
||||||
assert r.json()["error"]["code"] == "ACCOUNT_DISABLED"
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_is_audited_without_secrets(client: TestClient, engine):
|
|
||||||
from app.models import AuditLog
|
|
||||||
|
|
||||||
uid = _register(client).json()["id"]
|
|
||||||
with Session(engine) as s:
|
|
||||||
logs = s.exec(select(AuditLog).where(AuditLog.entity_id == uid)).all()
|
|
||||||
assert {(log.action, (log.payload or {}).get("provider")) for log in logs} >= {
|
|
||||||
("create", "local"),
|
|
||||||
("login", "local"),
|
|
||||||
}
|
|
||||||
assert all(PASSWORD not in str(log.payload) for log in logs)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Защита от перебора ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_throttle_blocks_after_five_failures(client: TestClient, monkeypatch):
|
|
||||||
import app.core.ratelimit as ratelimit
|
|
||||||
|
|
||||||
now = [1000.0]
|
|
||||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
|
||||||
|
|
||||||
_register(client)
|
|
||||||
client.cookies.clear()
|
|
||||||
for _ in range(5):
|
|
||||||
assert _login(client, password="wrong-password").status_code == 401
|
|
||||||
|
|
||||||
blocked = _login(client) # даже верный пароль не проверяется
|
|
||||||
assert blocked.status_code == 429
|
|
||||||
err = blocked.json()["error"]
|
|
||||||
assert err["code"] == "TOO_MANY_ATTEMPTS"
|
|
||||||
assert 0 < err["details"]["retry_after"] <= 15 * 60 + 1
|
|
||||||
assert "fs_session" not in client.cookies
|
|
||||||
|
|
||||||
now[0] += 15 * 60 # окно истекло
|
|
||||||
assert _login(client).status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_success_resets_pair_counter(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
client.cookies.clear()
|
|
||||||
for _ in range(4):
|
|
||||||
assert _login(client, password="wrong-password").status_code == 401
|
|
||||||
assert _login(client).status_code == 200
|
|
||||||
|
|
||||||
client.cookies.clear()
|
|
||||||
for _ in range(4):
|
|
||||||
assert _login(client, password="wrong-password").status_code == 401
|
|
||||||
assert _login(client).status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_throttle_per_ip_across_logins(client: TestClient):
|
|
||||||
"""С одного адреса нельзя перебирать пароли по многим логинам: 20 неудач — блок."""
|
|
||||||
for i in range(20):
|
|
||||||
assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401
|
|
||||||
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
|
|
||||||
|
|
||||||
|
|
||||||
def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch):
|
|
||||||
"""Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в
|
|
||||||
IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся."""
|
|
||||||
import app.core.ratelimit as ratelimit
|
|
||||||
from app.auth.password import _ACCOUNT_LIMIT, login_player
|
|
||||||
from app.core.errors import InvalidCredentialsError, TooManyAttemptsError
|
|
||||||
|
|
||||||
now = [3000.0]
|
|
||||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
|
||||||
_register(client, nickname="Жертва", password=PASSWORD)
|
|
||||||
|
|
||||||
with Session(engine) as s:
|
|
||||||
for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес
|
|
||||||
with pytest.raises(InvalidCredentialsError):
|
|
||||||
login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}")
|
|
||||||
# ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт
|
|
||||||
with pytest.raises(TooManyAttemptsError):
|
|
||||||
login_player(s, "Жертва", "wrong-password", ip="203.0.113.7")
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Установка и смена пароля ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _set_password(client: TestClient, new: str, current: str | None = None):
|
|
||||||
body = {"new_password": new}
|
|
||||||
if current is not None:
|
|
||||||
body["current_password"] = current
|
|
||||||
return client.put("/api/users/me/password", json=body, headers=csrf_headers(client))
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_login(client: TestClient, monkeypatch, **fields):
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
|
||||||
return client.post(
|
|
||||||
"/api/auth/telegram",
|
|
||||||
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_user_sets_password_then_logs_in(client: TestClient, monkeypatch):
|
|
||||||
"""Сценарий 1 и существующие аккаунты: без пароля → задаёт без текущего → входит по нику."""
|
|
||||||
r = _telegram_login(client, monkeypatch)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
me = r.json()
|
|
||||||
assert me["has_password"] is False
|
|
||||||
|
|
||||||
r2 = _set_password(client, PASSWORD)
|
|
||||||
assert r2.status_code == 200, r2.text
|
|
||||||
assert r2.json()["has_password"] is True
|
|
||||||
|
|
||||||
client.cookies.clear()
|
|
||||||
r3 = _login(client, nickname=me["nickname"])
|
|
||||||
assert r3.status_code == 200, r3.text
|
|
||||||
assert r3.json()["id"] == me["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_password_requires_current(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
|
|
||||||
missing = _set_password(client, "new-password-1")
|
|
||||||
assert missing.status_code == 403
|
|
||||||
assert missing.json()["error"]["code"] == "WRONG_CURRENT_PASSWORD"
|
|
||||||
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
|
|
||||||
|
|
||||||
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
|
|
||||||
client.cookies.clear()
|
|
||||||
assert _login(client).status_code == 401
|
|
||||||
assert _login(client, password="new-password-1").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_password_validates_new(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
r = _set_password(client, "short", current=PASSWORD)
|
|
||||||
assert r.status_code == 422
|
|
||||||
client.cookies.clear()
|
|
||||||
assert _login(client).status_code == 200 # старый пароль не тронут
|
|
||||||
|
|
||||||
|
|
||||||
def test_current_password_guessing_is_throttled(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
for _ in range(5):
|
|
||||||
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
|
|
||||||
blocked = _set_password(client, "new-password-1", current=PASSWORD)
|
|
||||||
assert blocked.status_code == 429
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_password_requires_session(client: TestClient):
|
|
||||||
assert _set_password(client, PASSWORD).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Отзыв токена: logout и смена пароля (#57, F2) ────────────────────────────
|
|
||||||
|
|
||||||
def _me_with_token(cookie_name: str, token: str):
|
|
||||||
"""Предъявить конкретный токен вручную (эмуляция «другого устройства»/украденной cookie)."""
|
|
||||||
return TestClient(app).get("/api/users/me", headers={"Cookie": f"{cookie_name}={token}"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_logout_revokes_presented_token(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
tok = client.cookies.get("fs_session")
|
|
||||||
assert _me_with_token("fs_session", tok).status_code == 200 # пока жив
|
|
||||||
|
|
||||||
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
|
||||||
# тот же токен, предъявленный после выхода, больше не принимается
|
|
||||||
assert _me_with_token("fs_session", tok).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_logout_does_not_revoke_other_devices(client: TestClient):
|
|
||||||
_register(client) # устройство A
|
|
||||||
tok_a = client.cookies.get("fs_session")
|
|
||||||
# устройство B: независимый вход тем же аккаунтом (свой jti)
|
|
||||||
b = TestClient(app)
|
|
||||||
assert b.post("/api/auth/login", json={"nickname": "Игрок", "password": PASSWORD}).status_code == 200
|
|
||||||
tok_b = b.cookies.get("fs_session")
|
|
||||||
assert tok_a and tok_b and tok_a != tok_b
|
|
||||||
|
|
||||||
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
|
||||||
assert _me_with_token("fs_session", tok_a).status_code == 401 # A вышел
|
|
||||||
assert _me_with_token("fs_session", tok_b).status_code == 200 # B не тронут
|
|
||||||
|
|
||||||
|
|
||||||
def test_password_change_revokes_old_sessions_keeps_current(client: TestClient):
|
|
||||||
_register(client)
|
|
||||||
old = client.cookies.get("fs_session")
|
|
||||||
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
|
|
||||||
# это устройство осталось в сессии (cookie перевыдан со свежим ver)
|
|
||||||
assert client.get("/api/users/me").status_code == 200
|
|
||||||
# старый токен (другое устройство/утёкший) отозван инкрементом token_version
|
|
||||||
assert _me_with_token("fs_session", old).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_password_reset_revokes_player_sessions(client: TestClient, monkeypatch, make_admin):
|
|
||||||
player = _telegram_login(client, monkeypatch).json()
|
|
||||||
stolen = client.cookies.get("fs_session") # действующая сессия игрока
|
|
||||||
assert _me_with_token("fs_session", stolen).status_code == 200
|
|
||||||
|
|
||||||
client.cookies.clear()
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
r = client.put(
|
|
||||||
f"/api/admin/users/{player['id']}/password",
|
|
||||||
json={"new_password": "from-admin-1"},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
# сброс пароля админом обрывает прежние сессии игрока (в т.ч. злоумышленника)
|
|
||||||
assert _me_with_token("fs_session", stolen).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Привязка Telegram ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _link_telegram(client: TestClient, monkeypatch, **fields):
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
|
||||||
return client.post(
|
|
||||||
"/api/users/me/telegram",
|
|
||||||
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_link_telegram_then_login_via_telegram(client: TestClient, monkeypatch):
|
|
||||||
"""Сценарий 2: аккаунт по паролю → привязал Telegram → вход через него в тот же аккаунт."""
|
|
||||||
uid = _register(client).json()["id"]
|
|
||||||
|
|
||||||
r = _link_telegram(client, monkeypatch) # id=777, тег ivan_tg
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
assert r.json()["telegram_id"] == 777
|
|
||||||
assert r.json()["nickname"] == "Игрок" # ник не меняется на тег
|
|
||||||
|
|
||||||
client.cookies.clear()
|
|
||||||
r2 = _telegram_login(client, monkeypatch)
|
|
||||||
assert r2.status_code == 200, r2.text
|
|
||||||
assert r2.json()["id"] == uid
|
|
||||||
assert r2.json()["nickname"] == "Игрок"
|
|
||||||
|
|
||||||
|
|
||||||
def test_link_telegram_taken_by_other_account(client: TestClient, monkeypatch):
|
|
||||||
assert _telegram_login(client, monkeypatch).status_code == 200 # 777 уже чей-то
|
|
||||||
client.cookies.clear()
|
|
||||||
_register(client)
|
|
||||||
|
|
||||||
r = _link_telegram(client, monkeypatch)
|
|
||||||
assert r.status_code == 409
|
|
||||||
assert r.json()["error"]["code"] == "TELEGRAM_TAKEN"
|
|
||||||
|
|
||||||
|
|
||||||
def test_link_telegram_twice(client: TestClient, monkeypatch):
|
|
||||||
_register(client)
|
|
||||||
assert _link_telegram(client, monkeypatch).status_code == 200
|
|
||||||
r = _link_telegram(client, monkeypatch, id=778)
|
|
||||||
assert r.status_code == 409
|
|
||||||
assert r.json()["error"]["code"] == "TELEGRAM_ALREADY_LINKED"
|
|
||||||
|
|
||||||
|
|
||||||
def test_link_telegram_bad_signature(client: TestClient, monkeypatch):
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
_register(client)
|
|
||||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
|
||||||
payload = _telegram_payload("TEST_BOT_TOKEN")
|
|
||||||
payload["hash"] = "deadbeef"
|
|
||||||
r = client.post("/api/users/me/telegram", json=payload, headers=csrf_headers(client))
|
|
||||||
assert r.status_code == 401
|
|
||||||
assert client.get("/api/users/me").json()["telegram_id"] is None
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Пароль игроку из админки ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _admin_login(client: TestClient, make_admin):
|
|
||||||
make_admin("boss", "secret123")
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/auth/login",
|
|
||||||
json={"username": "boss", "password": "secret123"},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
return r.json()["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_sets_player_password(client: TestClient, monkeypatch, make_admin):
|
|
||||||
player = _telegram_login(client, monkeypatch).json()
|
|
||||||
client.cookies.clear()
|
|
||||||
_admin_login(client, make_admin)
|
|
||||||
|
|
||||||
r = client.put(
|
|
||||||
f"/api/admin/users/{player['id']}/password",
|
|
||||||
json={"new_password": "from-admin-1"},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
assert r.json()["has_password"] is True
|
|
||||||
|
|
||||||
client.cookies.clear()
|
|
||||||
assert _login(client, nickname=player["nickname"], password="from-admin-1").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_set_admin_password(client: TestClient, make_admin):
|
|
||||||
admin_id = _admin_login(client, make_admin)
|
|
||||||
r = client.put(
|
|
||||||
f"/api/admin/users/{admin_id}/password",
|
|
||||||
json={"new_password": "from-admin-1"},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_player_cannot_set_passwords_via_admin(client: TestClient):
|
|
||||||
uid = _register(client).json()["id"]
|
|
||||||
r = client.put(
|
|
||||||
f"/api/admin/users/{uid}/password",
|
|
||||||
json={"new_password": "from-admin-1"},
|
|
||||||
headers=csrf_headers(client),
|
|
||||||
)
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Защита от перебора пароля администратора (#56, F1) ───────────────────────
|
|
||||||
|
|
||||||
def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch):
|
|
||||||
import app.core.ratelimit as ratelimit
|
|
||||||
|
|
||||||
now = [2000.0]
|
|
||||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
|
||||||
make_admin("boss", "secret123")
|
|
||||||
|
|
||||||
for _ in range(5):
|
|
||||||
r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"})
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
|
||||||
assert blocked.status_code == 429 # даже верный пароль не проверяется
|
|
||||||
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
|
|
||||||
|
|
||||||
now[0] += 15 * 60 # окно истекло
|
|
||||||
ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
|
||||||
assert ok.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine):
|
|
||||||
from app.models import AuditLog
|
|
||||||
|
|
||||||
make_admin("boss", "secret123")
|
|
||||||
assert client.post(
|
|
||||||
"/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"}
|
|
||||||
).status_code == 401
|
|
||||||
|
|
||||||
with Session(engine) as s:
|
|
||||||
logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all()
|
|
||||||
assert any(
|
|
||||||
log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs
|
|
||||||
)
|
|
||||||
assert all("nope-secret-guess" not in str(log.payload) for log in logs)
|
|
||||||
@@ -322,10 +322,9 @@ def test_history_excludes_matches_without_the_player(client: TestClient, engine)
|
|||||||
|
|
||||||
|
|
||||||
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
|
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
|
||||||
"""Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую.
|
"""Режим best берёт партию с максимальными League Points, а не самую свежую.
|
||||||
|
|
||||||
Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место
|
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
|
||||||
в дуэли — отнимает."""
|
|
||||||
me = login(client, "Лучший")
|
me = login(client, "Лучший")
|
||||||
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
|
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
|
||||||
|
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
"""Движок рейтинга: примеры 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)
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"""Итоги партии для рейтинга (#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
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
"""Рейтинг в витринах (#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
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Сглаживание рейтинга: 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 __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
from app.services import stats_service
|
|
||||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +31,10 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic
|
|||||||
|
|
||||||
|
|
||||||
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
|
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
|
||||||
"""Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
|
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать.
|
||||||
|
|
||||||
|
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
|
||||||
|
scoring.smoothed_score); этот тест ловит их расхождение."""
|
||||||
me, gid, p2 = _group_with_matches(client, engine, games=4)
|
me, gid, p2 = _group_with_matches(client, engine, games=4)
|
||||||
|
|
||||||
profile = client.get("/api/users/me/stats").json()["overall"]
|
profile = client.get("/api/users/me/stats").json()["overall"]
|
||||||
@@ -44,25 +47,33 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine):
|
|||||||
assert profile[field] == entry[field], field
|
assert profile[field] == entry[field], field
|
||||||
|
|
||||||
|
|
||||||
def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
|
def test_home_does_not_repeat_scored_cte(client: TestClient, engine):
|
||||||
"""Главная грузит историю партий один раз: топ, профиль и итог активной группы
|
"""Главная делает не больше двух проходов по SCORED_CTE.
|
||||||
считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин."""
|
|
||||||
|
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
|
||||||
|
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
|
||||||
me, gid, p2 = _group_with_matches(client, engine, games=2)
|
me, gid, p2 = _group_with_matches(client, engine, games=2)
|
||||||
client.put(
|
client.put(
|
||||||
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
||||||
)
|
)
|
||||||
|
|
||||||
calls: list[int] = []
|
seen: list[str] = []
|
||||||
original = stats_service.load_history
|
|
||||||
|
|
||||||
def spy(session):
|
def before_execute(conn, cursor, statement, params, context, executemany):
|
||||||
calls.append(1)
|
if "WITH tie AS" in statement:
|
||||||
return original(session)
|
seen.append(statement)
|
||||||
|
|
||||||
monkeypatch.setattr(stats_service, "load_history", spy)
|
event.listen(engine, "before_cursor_execute", before_execute)
|
||||||
r = client.get("/api/home")
|
try:
|
||||||
assert r.status_code == 200, r.text
|
r = client.get("/api/home")
|
||||||
assert len(calls) == 1
|
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)}"
|
||||||
# Главная всё ещё показывает и профиль, и блок активной группы.
|
# Главная всё ещё показывает и профиль, и блок активной группы.
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["profile"]["overall"]["games"] == 2
|
assert body["profile"]["overall"]["games"] == 2
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"""Ошибки валидации запроса: всегда 422 в едином конверте и без эха тела запроса."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_json_body_is_422_not_500(client: TestClient):
|
|
||||||
"""HTML-форма шлёт text/plain: тело приходит сырыми bytes, ответ раньше падал в 500."""
|
|
||||||
for path, raw in [
|
|
||||||
("/api/auth/telegram", '{"id": 1}'),
|
|
||||||
("/api/admin/auth/login", '{"username": "a", "password": "b"}'),
|
|
||||||
]:
|
|
||||||
r = client.post(path, content=raw, headers={"Content-Type": "text/plain"})
|
|
||||||
assert r.status_code == 422, (path, r.text)
|
|
||||||
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
|
||||||
|
|
||||||
|
|
||||||
def test_validation_error_does_not_echo_body(client: TestClient):
|
|
||||||
secret = "very-secret-password"
|
|
||||||
r = client.post("/api/admin/auth/login", json={"password": secret})
|
|
||||||
assert r.status_code == 422, r.text
|
|
||||||
assert secret not in r.text
|
|
||||||
|
|
||||||
details = r.json()["error"]["details"]
|
|
||||||
assert details and all(set(d) == {"type", "loc", "msg"} for d in details)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validation_error_points_to_field(client: TestClient):
|
|
||||||
r = client.post("/api/admin/auth/login", json={"password": "x"})
|
|
||||||
locs = [d["loc"] for d in r.json()["error"]["details"]]
|
|
||||||
assert ["body", "username"] in locs
|
|
||||||
+24
-27
@@ -1,6 +1,6 @@
|
|||||||
# Публикация: домены, VPS, туннели
|
# Публикация: домены, VPS, туннели
|
||||||
|
|
||||||
Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет
|
Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет
|
||||||
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
|
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
|
||||||
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
|
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
|
||||||
приложение само открывает к VPS.
|
приложение само открывает к VPS.
|
||||||
@@ -11,40 +11,37 @@ HTTPS твоими сертификатами и проксирует трафи
|
|||||||
│ ▲ туннель-КОНТЕЙНЕР │
|
│ ▲ туннель-КОНТЕЙНЕР │
|
||||||
│ └── Pi : app:8000 PROD │
|
│ └── Pi : app:8000 PROD │
|
||||||
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
|
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
|
||||||
│ ▲ ssh с ПК (по требованию) │
|
│ ▲ контейнер (test) ИЛИ │
|
||||||
│ └── ПК dev : vite:5173 DEV │
|
│ ▲ ssh с ПК (dev) │
|
||||||
|
│ ├── ПК test : app:8000 │
|
||||||
|
│ └── ПК dev : vite:5173 │
|
||||||
└───────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **PROD** — Pi. `docker compose up -d` поднимает три сервиса: `app` + `tunnel` + `backup`
|
- **PROD** — Pi. `docker compose up -d` поднимает два сервиса: `app` + `tunnel`. У `app`
|
||||||
(образы из Gitea-реестра, собираются на ПК `scripts/build-push.ps1`). У `app` **портов на
|
**портов на хост нет** — наружу его выставляет только туннель-контейнер
|
||||||
хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS).
|
(`ssh -R 9000:app:8000` к VPS). Постоянно, Docker сам переподключает. См. [`pi/`](pi/README.md).
|
||||||
Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер
|
- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` поднимает
|
||||||
(`restart: unless-stopped`). См. [`pi/`](pi/README.md).
|
`app` + `tunnel` (`ssh -R 9001:app:8000`). Портов на хост нет — тест виден только на
|
||||||
|
`forbidden-stars.ru`. Обычно запускается лаунчером при `APP_ENV=test`.
|
||||||
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
|
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
|
||||||
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК
|
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1`) дополнительно поднимает SSH-туннель с ПК
|
||||||
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**).
|
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`.
|
||||||
- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev.
|
- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**.
|
||||||
Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя.
|
PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо.
|
||||||
|
|
||||||
Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя
|
Ключ туннеля — **`deploy/tunnel/id_tunnel`** (приватный, в git не идёт). Его публичную
|
||||||
`tunnel` на VPS):
|
часть добавь в `authorized_keys` пользователя `tunnel` на VPS. Один и тот же ключ годится
|
||||||
- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет.
|
для контейнерного туннеля (Pi/ПК) и для dev-туннеля `run.ps1`.
|
||||||
- **ПК, временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер.
|
|
||||||
- **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по
|
|
||||||
умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`).
|
|
||||||
|
|
||||||
Настройка по шагам:
|
Настройка по шагам:
|
||||||
1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`.
|
1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`.
|
||||||
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`.
|
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`.
|
||||||
3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод,
|
3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или
|
||||||
файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`.
|
ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`.
|
||||||
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
|
|
||||||
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК.
|
|
||||||
|
|
||||||
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на
|
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК,
|
||||||
VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и
|
в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны.
|
||||||
`deploy/backup/` и шаблоны.
|
|
||||||
|
|
||||||
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
|
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
|
||||||
> (`forbiddenstars.ru` и `forbidden-stars.ru`).
|
> (`forbiddenstars.ru` и `forbidden-stars.ru`).
|
||||||
@@ -62,6 +59,6 @@ VPS/Pi/ПК, в репозитории только `Caddyfile`, страниц
|
|||||||
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
|
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
|
||||||
|
|
||||||
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
|
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
|
||||||
(10 с в `entrypoint.sh`, 2 с в `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
|
(`entrypoint.sh`, `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
|
||||||
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
|
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
|
||||||
по `stop_grace_period`.
|
по `stop_grace_period`.
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# В контекст сборки попадают ТОЛЬКО файлы образа. Всё остальное (в первую очередь приватный
|
|
||||||
# ключ deploy/backup/id_backup, если он лежит здесь на ПК) в образ не попадёт.
|
|
||||||
*
|
|
||||||
!Dockerfile
|
|
||||||
!fs-backup.sh
|
|
||||||
!entrypoint.sh
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Контейнер бэкапов: restic (дедупликация, шифрование, сжатие zstd) + расписание supercronic.
|
|
||||||
# Снимает консистентную копию SQLite и файлы томов приложения, хранит снимки локально (том
|
|
||||||
# backup-data) и на VPS (SFTP). Управление — команда fs-backup (см. deploy/backup/README.md).
|
|
||||||
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
|
|
||||||
|
|
||||||
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
|
||||||
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
|
||||||
RUN addgroup -g 10001 fsbackup \
|
|
||||||
&& adduser -D -u 10001 -G fsbackup -h /home/fsbackup fsbackup \
|
|
||||||
&& mkdir -p /fs/uploads /fs/achievements /fs-db /backup/repo /backup/state /backup/cache /import \
|
|
||||||
&& chown -R 10001:10001 /fs /fs-db /backup /import /home/fsbackup
|
|
||||||
|
|
||||||
COPY fs-backup.sh /usr/local/bin/fs-backup
|
|
||||||
COPY entrypoint.sh /usr/local/bin/fs-backup-entrypoint
|
|
||||||
RUN sed -i 's/\r$//' /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint \
|
|
||||||
&& chmod 755 /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint
|
|
||||||
|
|
||||||
ENV HOME=/home/fsbackup \
|
|
||||||
RESTIC_CACHE_DIR=/backup/cache \
|
|
||||||
TZ=Europe/Moscow
|
|
||||||
|
|
||||||
USER 10001:10001
|
|
||||||
WORKDIR /home/fsbackup
|
|
||||||
|
|
||||||
# Здоров = последний успешный бэкап свежее BACKUP_MAX_AGE_HOURS (в каждом репозитории).
|
|
||||||
HEALTHCHECK --interval=10m --timeout=60s --start-period=2h --retries=1 \
|
|
||||||
CMD ["fs-backup", "health"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/fs-backup-entrypoint"]
|
|
||||||
@@ -1,995 +0,0 @@
|
|||||||
# Бэкапы Forbidden Stars
|
|
||||||
|
|
||||||
Пошаговая инструкция: как включить бэкапы, проверить, что они работают, скачать их на ПК
|
|
||||||
и восстановить данные — в том числе на новом Pi, если старый умер.
|
|
||||||
|
|
||||||
Команды даны целиком — копируйте их как есть. Где нужно подставить своё значение, это
|
|
||||||
написано в угловых скобках: `<IP-адрес-Pi>`. Каждый шаг заканчивается блоком
|
|
||||||
**«Что должно получиться»** — не переходите к следующему шагу, пока не получили то же самое.
|
|
||||||
|
|
||||||
## Содержание
|
|
||||||
|
|
||||||
0. [Как это устроено](#0-как-это-устроено)
|
|
||||||
1. [Пароль шифрования](#шаг-1-пароль-шифрования) — ПК
|
|
||||||
2. [SSH-ключ для VPS](#шаг-2-ssh-ключ-для-vps) — ПК
|
|
||||||
3. [VPS: пользователь только для SFTP](#шаг-3-vps-пользователь-только-для-sftp) — VPS
|
|
||||||
4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК
|
|
||||||
5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi
|
|
||||||
6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК
|
|
||||||
7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК
|
|
||||||
8. [Восстановление прода](#8-восстановление-прода) — Pi
|
|
||||||
9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi
|
|
||||||
10. [Повседневные действия](#10-повседневные-действия)
|
|
||||||
11. [Неполадки](#11-неполадки)
|
|
||||||
12. [Справочник: команды и переменные](#12-справочник-команды-и-переменные)
|
|
||||||
13. [Итоговый чек-лист](#13-итоговый-чек-лист)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Как это устроено
|
|
||||||
|
|
||||||
```
|
|
||||||
Raspberry Pi (прод) VPS 186.246.51.17
|
|
||||||
┌──────────────────────────────────────────┐ ┌─────────────────────────────┐
|
|
||||||
│ app ──► тома: БД, uploads, achievements │ │ /srv/fs-backups/restic │
|
|
||||||
│ ▲ читает (консистентно) │ SFTP │ (копия №2, зашифрована) │
|
|
||||||
│ backup ──────┘ │ ────────────► │ пользователь fsbackup: │
|
|
||||||
│ │ каждую ночь в 04:00 │ (Pi сам │ только SFTP, без shell │
|
|
||||||
│ ▼ │ ходит └─────────────────────────────┘
|
|
||||||
│ том backup-data (копия №1, зашифрована) │ наружу)
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
▲ ssh + scp по команде «pull»
|
|
||||||
┌─────────┴────────────────────────────────┐
|
|
||||||
│ ПК: backups\fs_<дата>_<id>.tar │ копия №3, по запросу, НЕ зашифрована
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
На Pi работает третий контейнер — `backup`. В нём [restic](https://restic.net) — известная
|
|
||||||
программа для бэкапов. Каждую ночь контейнер:
|
|
||||||
|
|
||||||
1. снимает **консистентную** копию БД. Сайт при этом работает, пользователи ничего не замечают;
|
|
||||||
2. проверяет копию (`PRAGMA integrity_check`). Битая копия не сохраняется, старые снимки
|
|
||||||
тоже не трогаются;
|
|
||||||
3. сохраняет **снимок** — БД + загруженные фото (`uploads`) + титулы (`achievements`) — в
|
|
||||||
два места: на сам Pi и на VPS;
|
|
||||||
4. удаляет старые снимки по правилам хранения;
|
|
||||||
5. проверяет, что репозитории целы.
|
|
||||||
|
|
||||||
Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных (10 %) и
|
|
||||||
убеждается, что БД из последнего снимка извлекается и проходит проверку целостности.
|
|
||||||
|
|
||||||
**Словарь**
|
|
||||||
|
|
||||||
| Слово | Что значит |
|
|
||||||
|---|---|
|
|
||||||
| **Снимок** (snapshot) | Состояние данных на момент бэкапа. У каждого есть короткий ID, например `3f2a9c1d`. |
|
|
||||||
| **Репозиторий** | Хранилище снимков. У нас их два: `local` (на Pi) и `vps` (на VPS). |
|
|
||||||
| **Пароль шифрования** | Им зашифрованы оба репозитория. Без него снимки прочитать нельзя — **никак**. |
|
|
||||||
| **Хранение** | Сколько снимков остаётся: последний за каждый из 14 дней, за каждую из 8 недель, за каждый из 12 месяцев, плюс 3 самых свежих. Одинаковые данные хранятся один раз (дедупликация), поэтому 30+ снимков занимают немногим больше одного. |
|
|
||||||
| **Именованный снимок** | Снимок, сделанный вручную с меткой (`--tag before-update`). Автоматически не удаляется. |
|
|
||||||
| **pre-restore** | Страховочный снимок, который автоматически делается перед каждым восстановлением: «как было до». Автоматически не удаляется. |
|
|
||||||
|
|
||||||
**Что попадает в снимок:** БД, `uploads`, `achievements`.
|
|
||||||
**Что НЕ попадает:** файл `.env` с секретами (пароли, токен бота, ключи). Его копию храните
|
|
||||||
отдельно — см. [шаг 1](#шаг-1-пароль-шифрования).
|
|
||||||
|
|
||||||
**Ограничения, о которых стоит знать**
|
|
||||||
|
|
||||||
- Копии на ПК (`backups\*.tar`) **не зашифрованы**: там данные игроков. Не выкладывайте их
|
|
||||||
никуда и удаляйте ненужные.
|
|
||||||
- Ключ, которым Pi заходит на VPS, умеет и удалять файлы в `/srv/fs-backups`. Если Pi будет
|
|
||||||
взломан, злоумышленник сможет удалить копию на VPS. Поэтому раз в месяц полезно скачивать
|
|
||||||
снимок на ПК ([раздел 10](#10-повседневные-действия)).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 1. Пароль шифрования
|
|
||||||
|
|
||||||
**Где:** ПК. **Сколько времени:** 5 минут.
|
|
||||||
|
|
||||||
> ### ⚠️ Самое важное во всей инструкции
|
|
||||||
> Потеряете пароль — **ни один бэкап не восстановить**. Ни на Pi, ни на VPS. Никакого
|
|
||||||
> «сброса пароля» у restic нет, и это не баг, а суть шифрования.
|
|
||||||
|
|
||||||
1. Откройте PowerShell и сгенерируйте пароль:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
```
|
|
||||||
|
|
||||||
Если `python` не найден, подойдёт такая команда:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$b = New-Object byte[] 32; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); [Convert]::ToBase64String($b).TrimEnd('=').Replace('+','-').Replace('/','_')
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Сохраните результат в менеджер паролей (Bitwarden, KeePass, 1Password…), запись
|
|
||||||
назовите, например, **«Forbidden Stars — пароль бэкапов (restic)»**.
|
|
||||||
3. Сделайте вторую копию вне компьютера: распечатайте или запишите на бумагу и уберите
|
|
||||||
в надёжное место.
|
|
||||||
4. **Рекомендуется:** в той же записи менеджера паролей храните копию файла `.env` с Pi
|
|
||||||
(после шага 5). При гибели Pi это сэкономит час восстановления секретов.
|
|
||||||
|
|
||||||
**Что должно получиться:** строка из ~43 символов (латиница, цифры, `-`, `_`), сохранённая
|
|
||||||
в менеджере паролей и на бумаге.
|
|
||||||
|
|
||||||
> В пароле не должно быть символа `$` — docker compose воспринимает его как переменную.
|
|
||||||
> Обе команды выше `$` не генерируют.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 2. SSH-ключ для VPS
|
|
||||||
|
|
||||||
**Где:** ПК, PowerShell, папка репозитория. **Зачем:** этим ключом контейнер `backup`
|
|
||||||
на Pi будет заходить на VPS. Пароль к ключу не ставим: контейнер работает без человека.
|
|
||||||
|
|
||||||
1. Перейдите в папку репозитория (подставьте свой путь):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd C:\Users\<вы>\ForbidenStarsApp
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Создайте ключ:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
ssh-keygen -t ed25519 -C "fs-backup" -f deploy\backup\id_backup
|
|
||||||
```
|
|
||||||
|
|
||||||
На вопросы `Enter passphrase` и `Enter same passphrase again` просто **дважды нажмите
|
|
||||||
Enter** (пароль к ключу пустой).
|
|
||||||
|
|
||||||
3. Проверьте, что появились два файла:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-ChildItem deploy\backup\id_backup*
|
|
||||||
Get-Content deploy\backup\id_backup.pub
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Проверьте, что ключ не попадёт в git (команда должна вывести **пустоту**):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git status --short deploy\backup
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что должно получиться:**
|
|
||||||
- файлы `id_backup` (приватный, секрет) и `id_backup.pub` (публичный);
|
|
||||||
- содержимое `id_backup.pub` — одна строка вида `ssh-ed25519 AAAAC3Nza…много символов… fs-backup`;
|
|
||||||
- `git status` по этой папке ничего не показывает.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 3. VPS: пользователь только для SFTP
|
|
||||||
|
|
||||||
**Где:** VPS. **Зачем:** отдельный пользователь `fsbackup` сможет только класть и читать
|
|
||||||
файлы бэкапов по SFTP: без shell, без туннелей, без входа по паролю.
|
|
||||||
|
|
||||||
> Почему `fsbackup`, а не `backup`: в Debian/Ubuntu системный пользователь `backup` уже
|
|
||||||
> существует (он служебный, домашняя папка `/var/backups`) — его трогать нельзя.
|
|
||||||
|
|
||||||
1. Зайдите на VPS (с ПК):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
ssh root@186.246.51.17
|
|
||||||
```
|
|
||||||
|
|
||||||
Все команды ниже выполняются **на VPS** от root. Заходите не под root — добавляйте
|
|
||||||
`sudo` перед каждой командой.
|
|
||||||
|
|
||||||
2. Убедитесь, что такого пользователя ещё нет:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
id fsbackup
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидается `id: 'fsbackup': no such user`. Если пользователь уже есть — пропустите пункт 3.
|
|
||||||
|
|
||||||
3. Создайте пользователя и папки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
useradd --create-home --shell /usr/sbin/nologin fsbackup
|
|
||||||
install -d -m 700 -o fsbackup -g fsbackup /home/fsbackup/.ssh
|
|
||||||
install -m 600 -o fsbackup -g fsbackup /dev/null /home/fsbackup/.ssh/authorized_keys
|
|
||||||
install -d -m 700 -o fsbackup -g fsbackup /srv/fs-backups
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Добавьте публичный ключ из шага 2.
|
|
||||||
- **На ПК**, во втором окне PowerShell в папке репозитория, скопируйте ключ в буфер обмена:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-Content deploy\backup\id_backup.pub | Set-Clipboard
|
|
||||||
```
|
|
||||||
|
|
||||||
- **На VPS** наберите команду ниже, вставив ключ вместо `ВСТАВЬТЕ_КЛЮЧ`. Вставка в
|
|
||||||
терминале — правая кнопка мыши или `Ctrl+Shift+V`. Одинарные кавычки оставьте:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo 'ВСТАВЬТЕ_КЛЮЧ' >> /home/fsbackup/.ssh/authorized_keys
|
|
||||||
cat /home/fsbackup/.ssh/authorized_keys
|
|
||||||
```
|
|
||||||
|
|
||||||
`cat` должен показать одну строку, которая начинается с `ssh-ed25519` и заканчивается на `fs-backup`.
|
|
||||||
|
|
||||||
5. Запретите этому пользователю всё, кроме SFTP. Скопируйте блок **целиком**, от `cat` до
|
|
||||||
последнего `EOF` включительно:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat > /etc/ssh/sshd_config.d/60-fs-backup.conf <<'EOF'
|
|
||||||
Match User fsbackup
|
|
||||||
ForceCommand internal-sftp -d /srv/fs-backups
|
|
||||||
PasswordAuthentication no
|
|
||||||
AllowTcpForwarding no
|
|
||||||
AllowAgentForwarding no
|
|
||||||
PermitTunnel no
|
|
||||||
X11Forwarding no
|
|
||||||
PermitTTY no
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Убедитесь, что основной конфиг подключает папку `sshd_config.d`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n '^Include' /etc/ssh/sshd_config
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидается строка `Include /etc/ssh/sshd_config.d/*.conf`. **Если вывода нет** (старая
|
|
||||||
система), допишите блок в конец основного конфига:
|
|
||||||
`cat /etc/ssh/sshd_config.d/60-fs-backup.conf >> /etc/ssh/sshd_config`.
|
|
||||||
|
|
||||||
7. Проверьте конфиг и примените его. Текущая SSH-сессия при этом не оборвётся:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sshd -t && echo "конфиг OK"
|
|
||||||
systemctl reload ssh || systemctl reload sshd
|
|
||||||
```
|
|
||||||
|
|
||||||
Если `sshd -t` вывел ошибку, **не выходите из сессии** и исправьте файл
|
|
||||||
(`nano /etc/ssh/sshd_config.d/60-fs-backup.conf`). Ошибка в конфиге sshd может закрыть
|
|
||||||
вход на сервер.
|
|
||||||
|
|
||||||
8. Проверьте, что правила действуют только на `fsbackup`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sshd -T -C user=fsbackup,host=x,addr=1.2.3.4 | grep -i forcecommand
|
|
||||||
sshd -T -C user=root,host=x,addr=1.2.3.4 | grep -i forcecommand
|
|
||||||
```
|
|
||||||
|
|
||||||
Первая команда должна показать `forcecommand internal-sftp -d /srv/fs-backups`,
|
|
||||||
вторая — `forcecommand none`.
|
|
||||||
|
|
||||||
9. **С ПК** (новое окно PowerShell в папке репозитория) проверьте вход по SFTP:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
sftp -i deploy\backup\id_backup fsbackup@186.246.51.17
|
|
||||||
```
|
|
||||||
|
|
||||||
При первом подключении ответьте `yes` на вопрос `Are you sure you want to continue
|
|
||||||
connecting`. Появится приглашение `sftp>`. Наберите `pwd`, затем `bye`.
|
|
||||||
|
|
||||||
10. **С ПК** проверьте, что shell закрыт:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
ssh -i deploy\backup\id_backup fsbackup@186.246.51.17
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что должно получиться:**
|
|
||||||
- `sshd -t` → `конфиг OK`;
|
|
||||||
- в `sftp` команда `pwd` отвечает `Remote working directory: /srv/fs-backups`;
|
|
||||||
- `ssh` из пункта 10 отвечает `This service allows sftp connections only.` и сразу отключается;
|
|
||||||
- вход root на VPS работает как раньше (проверьте новым окном: `ssh root@186.246.51.17`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 4. Сборка и публикация образов
|
|
||||||
|
|
||||||
**Где:** ПК с Docker Desktop, папка репозитория. **Зачем:** на Pi нет сборки — он скачивает
|
|
||||||
готовые образы из реестра Gitea. Новый образ `forbidden-stars-backup` нужно туда положить.
|
|
||||||
|
|
||||||
> `build-push` собирает **все три** образа (app, tunnel, backup) из текущей ветки ПК и
|
|
||||||
> публикует их с тегом из `IMAGE_TAG`. Убедитесь, что вы на ветке, которая должна быть на
|
|
||||||
> проде (обычно `main` после релиза): `git branch --show-current`.
|
|
||||||
|
|
||||||
1. Один раз войдите в реестр (если уже входили — пропустите):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker login gitea.arseniev.info
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Соберите и опубликуйте образы (первый раз — 5–15 минут):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\scripts\build-push.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Проверьте, что образ бэкапа есть в реестре и собран под arm64:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker buildx imagetools inspect gitea.arseniev.info/notbigghost/forbidden-stars-backup:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что должно получиться:**
|
|
||||||
- `build-push.ps1` заканчивается зелёной строкой `Done. On the Pi: ...`;
|
|
||||||
- `imagetools inspect` показывает `Platform: linux/arm64`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 5. Pi: включить бэкапы
|
|
||||||
|
|
||||||
**Где:** Pi. **Сколько времени:** 15 минут.
|
|
||||||
|
|
||||||
1. Зайдите на Pi и перейдите в папку прода:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
ssh pi@<IP-адрес-Pi>
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/forbidden-stars
|
|
||||||
ls
|
|
||||||
```
|
|
||||||
|
|
||||||
Должны быть видны `docker-compose.yml` и `.env`. Если папка называется иначе, дальше
|
|
||||||
везде используйте своё название.
|
|
||||||
|
|
||||||
2. Сохраните копию текущего compose-файла — на случай отката:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp docker-compose.yml docker-compose.yml.bak-$(date +%F)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Скачайте новый `docker-compose.yml`. Замените `main` на `dev`, если функционал ещё не
|
|
||||||
попал в релиз:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BRANCH=main
|
|
||||||
curl -fsSLO https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/$BRANCH/docker-compose.yml
|
|
||||||
grep -n '^ backup:' docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
`grep` должен найти строку ` backup:`. Если не нашёл, в этой ветке функционала ещё нет.
|
|
||||||
|
|
||||||
4. Посмотрите, какие `BACKUP_*` уже есть в `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n '^BACKUP_' .env
|
|
||||||
```
|
|
||||||
|
|
||||||
- Есть **старые** строки (`BACKUP_VPS_USER=backup`, `BACKUP_VPS_DIR=/srv/fs-backups`,
|
|
||||||
`BACKUP_VPS_KEY`, `BACKUP_KEEP_LOCAL`, `BACKUP_KEEP_REMOTE`)? **Удалите их все** в
|
|
||||||
пункте 5: они указывают на неправильного пользователя и папку.
|
|
||||||
- Уже есть **новые** строки (`BACKUP_PASSWORD`, `BACKUP_SSH_KEY_B64` и т.д. — если `.env`
|
|
||||||
делался из свежего `.env.example`)? Блок ниже **не дописывайте**, а заполните
|
|
||||||
существующие строки теми же значениями. Иначе переменные задвоятся.
|
|
||||||
|
|
||||||
5. Откройте `.env` и добавьте в конец блок ниже:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
В nano: стрелками вниз до конца файла, вставка — правая кнопка мыши, сохранить —
|
|
||||||
`Ctrl+O`, затем `Enter`, выйти — `Ctrl+X`.
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# ─── БЭКАПЫ ───
|
|
||||||
BACKUP_PASSWORD=<пароль из шага 1>
|
|
||||||
BACKUP_SCHEDULE="0 4 * * *"
|
|
||||||
BACKUP_VERIFY_SCHEDULE="30 5 * * 0"
|
|
||||||
BACKUP_TZ=Europe/Moscow
|
|
||||||
BACKUP_KEEP_DAILY=14
|
|
||||||
BACKUP_KEEP_WEEKLY=8
|
|
||||||
BACKUP_KEEP_MONTHLY=12
|
|
||||||
BACKUP_COMPRESSION=max
|
|
||||||
BACKUP_MAX_AGE_HOURS=30
|
|
||||||
BACKUP_VPS_HOST=186.246.51.17
|
|
||||||
BACKUP_VPS_USER=fsbackup
|
|
||||||
BACKUP_VPS_PORT=22
|
|
||||||
BACKUP_VPS_DIR=/srv/fs-backups/restic
|
|
||||||
BACKUP_SSH_KEY_B64=<длинная строка base64, см. ниже>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Как получить `BACKUP_SSH_KEY_B64`.** На ПК, в PowerShell в папке репозитория:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup"))) | Set-Clipboard
|
|
||||||
[Text.Encoding]::ASCII.GetString([Convert]::FromBase64String((Get-Clipboard))).Split("`n")[0]
|
|
||||||
```
|
|
||||||
|
|
||||||
Вторая команда проверяет содержимое буфера. Она должна вывести
|
|
||||||
`-----BEGIN OPENSSH PRIVATE KEY-----`. Теперь вставьте строку из буфера в `.env` после
|
|
||||||
`BACKUP_SSH_KEY_B64=`: **одной строкой, без пробелов и кавычек**.
|
|
||||||
|
|
||||||
**Что означает каждая строка**
|
|
||||||
|
|
||||||
| Переменная | Значение | Что делает |
|
|
||||||
|---|---|---|
|
|
||||||
| `BACKUP_PASSWORD` | пароль из шага 1 | шифрует оба репозитория; пусто = бэкапы выключены |
|
|
||||||
| `BACKUP_SCHEDULE` | `"0 4 * * *"` | когда делать бэкап: минута, час, день, месяц, день недели → каждый день в 04:00 |
|
|
||||||
| `BACKUP_VERIFY_SCHEDULE` | `"30 5 * * 0"` | когда проверять данные: воскресенье 05:30 |
|
|
||||||
| `BACKUP_TZ` | `Europe/Moscow` | часовой пояс расписания и времени снимков |
|
|
||||||
| `BACKUP_KEEP_DAILY/WEEKLY/MONTHLY` | `14` / `8` / `12` | сколько дней / недель / месяцев хранить снимки |
|
|
||||||
| `BACKUP_COMPRESSION` | `max` | сжатие restic: `auto` — быстрее, `max` — плотнее, `off` — без сжатия |
|
|
||||||
| `BACKUP_MAX_AGE_HOURS` | `30` | если успешного бэкапа не было дольше — контейнер помечается `unhealthy` |
|
|
||||||
| `BACKUP_VPS_HOST` | `186.246.51.17` | адрес VPS; пусто = копия только на Pi |
|
|
||||||
| `BACKUP_VPS_USER` | `fsbackup` | пользователь из шага 3 |
|
|
||||||
| `BACKUP_VPS_PORT` | `22` | SSH-порт VPS |
|
|
||||||
| `BACKUP_VPS_DIR` | `/srv/fs-backups/restic` | папка репозитория на VPS (создастся сама) |
|
|
||||||
| `BACKUP_SSH_KEY_B64` | base64 ключа | приватный ключ из шага 2 одной строкой |
|
|
||||||
|
|
||||||
6. Проверьте `.env`, **не показывая секреты на экране**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -c '^BACKUP_' .env
|
|
||||||
grep '^BACKUP_SSH_KEY_B64=' .env | wc -c
|
|
||||||
grep '^BACKUP_PASSWORD=' .env | grep -c '\$'
|
|
||||||
docker compose config --quiet && echo "compose OK"
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидается:
|
|
||||||
- `14` (или `16`, если в `.env` есть ещё `BACKUP_PI_SSH` и `BACKUP_PI_DIR` из нового шаблона —
|
|
||||||
на Pi они не используются и не мешают);
|
|
||||||
- число больше `400`;
|
|
||||||
- `0`;
|
|
||||||
- `compose OK`.
|
|
||||||
|
|
||||||
Проверить, что ни одна переменная не задвоилась (вывод должен быть **пустым**):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -o '^BACKUP_[A-Z_0-9]*' .env | sort | uniq -d
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Запустите **только** контейнер бэкапа. Сайт при этом не перезапускается:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d backup
|
|
||||||
```
|
|
||||||
|
|
||||||
8. Смотрите журнал первого запуска (выход — `Ctrl+C`, контейнер продолжит работать):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f backup
|
|
||||||
```
|
|
||||||
|
|
||||||
Первый бэкап начинается сразу. В журнале должны появиться, в таком порядке:
|
|
||||||
- `Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу.`
|
|
||||||
- `БД в порядке: игроков N, партий M.`
|
|
||||||
- `Репозиторий local ещё не создан — создаю …`
|
|
||||||
- `OK: репозиторий local, снимок xxxxxxxx.`
|
|
||||||
- `Репозиторий vps ещё не создан — создаю (sftp:fs-vps:/srv/fs-backups/restic …`
|
|
||||||
- `OK: репозиторий vps, снимок yyyyyyyy.`
|
|
||||||
- `Бэкап завершён.`
|
|
||||||
- `Расписание (TZ=Europe/Moscow):` и две строки расписания.
|
|
||||||
|
|
||||||
9. Проверьте состояние и хронологию:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backup fs-backup status
|
|
||||||
docker compose exec backup fs-backup list
|
|
||||||
docker compose exec backup fs-backup list vps
|
|
||||||
docker compose ps backup
|
|
||||||
```
|
|
||||||
|
|
||||||
10. На VPS убедитесь, что копия пришла (там только зашифрованные файлы restic):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -la /srv/fs-backups/restic
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что должно получиться:**
|
|
||||||
- в `status` у `[local]` и `[vps]` есть строка `Последний бэкап: <сегодня> — снимок …`;
|
|
||||||
- `list` и `list vps` показывают по одному снимку с верным числом игроков и партий;
|
|
||||||
- на VPS в `/srv/fs-backups/restic` лежат `config`, `data`, `index`, `keys`, `snapshots`;
|
|
||||||
- `docker compose ps backup` показывает `Up`. Первые ~10 минут статус `(health: starting)`,
|
|
||||||
затем `(healthy)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 6. ПК: доступ к Pi и выгрузка бэкапов
|
|
||||||
|
|
||||||
**Где:** ПК. **Зачем:** одной командой скачивать снимок с Pi на ПК — третья копия, которая
|
|
||||||
не зависит ни от Pi, ни от VPS.
|
|
||||||
|
|
||||||
1. Проверьте вход на Pi **без пароля**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
ssh pi@<IP-адрес-Pi> "echo ok && cd ~/forbidden-stars && docker compose ps backup"
|
|
||||||
```
|
|
||||||
|
|
||||||
Если выводится `ok` и строка контейнера `backup` без запроса пароля — переходите
|
|
||||||
к пункту 3.
|
|
||||||
|
|
||||||
2. Если спрашивает пароль, настройте вход по ключу (один раз).
|
|
||||||
|
|
||||||
Создайте ключ ПК, если его ещё нет. На вопросы `passphrase` дважды нажмите Enter:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
if (-not (Test-Path "$env:USERPROFILE\.ssh\id_ed25519")) { ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\id_ed25519" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Передайте публичный ключ на Pi (пароль Pi спросят в последний раз):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub" | ssh pi@<IP-адрес-Pi> "mkdir -p ~/.ssh && chmod 700 ~/.ssh && tr -d '\r' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
|
||||||
```
|
|
||||||
|
|
||||||
Повторите пункт 1 — пароль спрашиваться не должен.
|
|
||||||
|
|
||||||
3. Добавьте в `.env` **на ПК** (корень репозитория) или исправьте, если строки уже есть:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
BACKUP_PI_SSH=pi@<IP-адрес-Pi>
|
|
||||||
BACKUP_PI_DIR=~/forbidden-stars
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Проверьте скрипт:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\scripts\fs-backup.ps1 status
|
|
||||||
.\scripts\fs-backup.ps1 list
|
|
||||||
```
|
|
||||||
|
|
||||||
Если PowerShell пишет `running scripts is disabled on this system`, выполните один раз:
|
|
||||||
`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
|
|
||||||
|
|
||||||
5. Скачайте последний снимок:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\scripts\fs-backup.ps1 pull
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт делает всё сам:
|
|
||||||
- выгружает снимок в файл на Pi;
|
|
||||||
- копирует файл на ПК по `scp`;
|
|
||||||
- сверяет контрольную сумму sha256;
|
|
||||||
- проверяет, что внутри есть БД;
|
|
||||||
- удаляет временный файл на Pi.
|
|
||||||
|
|
||||||
6. Посмотрите, что внутри архива:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-ChildItem backups\fs_*.tar
|
|
||||||
tar -tf (Get-ChildItem backups\fs_*.tar | Sort-Object LastWriteTime | Select-Object -Last 1).FullName | Select-Object -First 10
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что должно получиться:**
|
|
||||||
- `pull` заканчивается зелёной строкой `OK: ...\backups\fs_<дата>_<время>_<id>.tar (… MB, N files, sha256 verified)`;
|
|
||||||
- внутри архива есть `forbidden_stars.db`, `uploads/…`, `achievements/…`.
|
|
||||||
|
|
||||||
> Архив — обычный `.tar`, его открывает 7-Zip. БД внутри — файл SQLite, его можно
|
|
||||||
> посмотреть в [DB Browser for SQLite](https://sqlitebrowser.org). Архив **не зашифрован**.
|
|
||||||
>
|
|
||||||
> Другие варианты: снимок с VPS — `.\scripts\fs-backup.ps1 pull -Repo vps`,
|
|
||||||
> конкретный снимок — `.\scripts\fs-backup.ps1 pull -Snapshot 3f2a9c1d` (ID из `list`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 7. Проверка скачанного архива
|
|
||||||
|
|
||||||
**Где:** ПК. **Зачем:** убедиться, что БД в скачанном снимке целая и в ней те данные, что
|
|
||||||
ожидаются, **до** того как это понадобится по-настоящему. Прод не затрагивается.
|
|
||||||
|
|
||||||
> Отдельного тестового контейнера для учебного восстановления больше нет. Сам механизм
|
|
||||||
> `restore`/`import` отрабатывает только на Pi ([раздел 8](#8-восстановление-прода)); здесь
|
|
||||||
> проверяется содержимое архива.
|
|
||||||
|
|
||||||
1. Распакуйте последний скачанный архив во временную папку:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$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
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Откройте `%TEMP%\fs-check\forbidden_stars.db` в [DB Browser for SQLite](https://sqlitebrowser.org)
|
|
||||||
(вкладка «Выполнить SQL») и выполните:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
PRAGMA integrity_check;
|
|
||||||
SELECT (SELECT count(*) FROM users WHERE role = 'player') AS players,
|
|
||||||
(SELECT count(*) FROM matches) AS matches;
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Закройте DB Browser и удалите временную папку — данные в ней не зашифрованы:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Remove-Item -Recurse -Force (Join-Path $env:TEMP "fs-check")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что должно получиться:**
|
|
||||||
- `PRAGMA integrity_check` → `ok`;
|
|
||||||
- `players` и `matches` совпадают со столбцами `Игроков` / `Партий` этого снимка в `list` на Pi;
|
|
||||||
- в папке рядом с БД есть `uploads\…` (фото партий) и, если заводились, `achievements\…`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Восстановление прода
|
|
||||||
|
|
||||||
**Когда:** данные испорчены или удалены по ошибке, неудачная миграция, «откатить на вчера».
|
|
||||||
|
|
||||||
### Как это работает (почему это безопасно)
|
|
||||||
|
|
||||||
Восстановление **никогда не пишет поверх текущих данных напрямую**:
|
|
||||||
|
|
||||||
1. **Проверка.** Если приложение работает, восстановление отказывается запускаться. Затем
|
|
||||||
проверяется, хватит ли места на диске.
|
|
||||||
2. **Разворачивание.** Снимок целиком разворачивается в промежуточную папку
|
|
||||||
`.restore-new` внутри каждого тома.
|
|
||||||
3. **Проверка развёрнутого:** целостность БД, наличие таблиц, число файлов совпадает со
|
|
||||||
снимком. Любая ошибка → промежуточная папка удаляется, **текущие данные не тронуты**.
|
|
||||||
4. **Страховка.** Текущие данные сохраняются в снимок `pre-restore`.
|
|
||||||
5. **Замена.** Текущие данные переносятся в `.restore-old`, новые — на их место.
|
|
||||||
Используется переименование: мгновенно, без копирования. Сбой на этом шаге → всё
|
|
||||||
возвращается как было.
|
|
||||||
6. **Уборка.** `.restore-old` удаляется.
|
|
||||||
|
|
||||||
### Порядок действий
|
|
||||||
|
|
||||||
1. Зайдите на Pi и выберите снимок:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/forbidden-stars
|
|
||||||
docker compose exec backup fs-backup list
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример вывода:
|
|
||||||
|
|
||||||
```
|
|
||||||
ID Время Игроков Партий Размер Прирост Метки
|
|
||||||
3f2a9c1d 2026-09-13 04:00 12 87 45.1 MB 12.3 KB scheduled
|
|
||||||
8b1e0f44 2026-09-14 04:00 12 88 45.2 MB 40.1 KB scheduled
|
|
||||||
```
|
|
||||||
|
|
||||||
| Столбец | Что показывает |
|
|
||||||
|---|---|
|
|
||||||
| `ID` | ID снимка — его нужно подставить в команду восстановления |
|
|
||||||
| `Время` | когда сделан снимок |
|
|
||||||
| `Игроков` / `Партий` | сколько игроков и партий было в БД в этот момент — по ним легко найти «до поломки» |
|
|
||||||
| `Размер` | полный объём данных снимка |
|
|
||||||
| `Прирост` | сколько места снимок реально добавил в репозиторий |
|
|
||||||
| `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, `keep` + имя — именованный снимок (`run --tag имя`) |
|
|
||||||
|
|
||||||
Если локальный репозиторий повреждён или пуст, смотрите копию на VPS:
|
|
||||||
`docker compose exec backup fs-backup list vps`.
|
|
||||||
|
|
||||||
2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК:
|
|
||||||
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем [шаг 7](#шаг-7-проверка-скачанного-архива).
|
|
||||||
Заодно у вас останется копия этого снимка вне Pi.
|
|
||||||
|
|
||||||
3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop app
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Восстановите снимок. С VPS — добавьте в конец `--repo vps`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backup fs-backup restore <ID> --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Запустите приложение и проверьте его:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose start app
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
Через 30–60 секунд у `app` должно быть `(healthy)`. Откройте `https://forbiddenstars.ru`.
|
|
||||||
|
|
||||||
**Что должно получиться:** в выводе `restore` — `Развёрнутые данные в порядке: …`,
|
|
||||||
`Страховочный снимок: <id>`, `Данные восстановлены.`; сайт работает на данных из снимка.
|
|
||||||
|
|
||||||
**Если что-то пошло не так**
|
|
||||||
|
|
||||||
- `restore` закончился строкой `ОШИБКА: … текущие данные НЕ тронуты` — данные прежние,
|
|
||||||
просто запустите приложение (`docker compose start app`) и разберитесь с причиной
|
|
||||||
([раздел 11](#11-неполадки)).
|
|
||||||
- Восстановили не тот снимок — верните «как было до»: в `list` найдите самый свежий снимок
|
|
||||||
с меткой `pre-restore` и восстановите его так же (пункты 3–5).
|
|
||||||
- После старта сайт показывает заглушку дольше пары минут: `docker compose logs --tail 50 app`,
|
|
||||||
затем `docker compose restart tunnel`.
|
|
||||||
|
|
||||||
### Восстановление из файла-архива
|
|
||||||
|
|
||||||
Например, из архива, скачанного на ПК, или из старого `fs_*.tar.gz`.
|
|
||||||
|
|
||||||
1. С ПК скопируйте архив на Pi:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
scp backups\fs_20260914_0400_3f2a9c1d.tar pi@<IP-адрес-Pi>:~/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. На Pi:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/forbidden-stars
|
|
||||||
docker compose cp ~/fs_20260914_0400_3f2a9c1d.tar backup:/import/fs.tar
|
|
||||||
docker compose stop app
|
|
||||||
docker compose exec backup fs-backup import /import/fs.tar --yes
|
|
||||||
docker compose exec backup rm /import/fs.tar
|
|
||||||
docker compose start app
|
|
||||||
rm ~/fs_20260914_0400_3f2a9c1d.tar
|
|
||||||
```
|
|
||||||
|
|
||||||
`import` проходит те же проверки и так же делает снимок `pre-restore`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Катастрофа: Pi умер
|
|
||||||
|
|
||||||
**Когда:** Pi сгорел, SD-карта испорчена, Pi украли. Копия на Pi потеряна, остаётся VPS
|
|
||||||
(и архивы на ПК).
|
|
||||||
|
|
||||||
**Что понадобится:**
|
|
||||||
- пароль бэкапов из менеджера паролей — **без него дальше идти бессмысленно**;
|
|
||||||
- копия `.env` старого Pi (если сохраняли, [шаг 1](#шаг-1-пароль-шифрования)).
|
|
||||||
|
|
||||||
1. Подготовьте новый Pi по [`deploy/pi/README.md`](../pi/README.md), пункты 1–3: Docker,
|
|
||||||
`docker-compose.yml`, `.env`.
|
|
||||||
- Есть копия старого `.env` — просто положите её.
|
|
||||||
- Нет копии — заполните `.env` заново. **`BACKUP_PASSWORD` должен быть прежним.**
|
|
||||||
Если ключа `deploy/backup/id_backup` на ПК больше нет, сделайте новый (шаг 2), добавьте
|
|
||||||
его `.pub` на VPS (шаг 3, пункт 4) и укажите новый base64 в `BACKUP_SSH_KEY_B64`.
|
|
||||||
2. Запустите всё:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/forbidden-stars
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
|
||||||
Контейнер `backup` стартует одновременно с `app` и сразу пробует сделать первый бэкап. В его
|
|
||||||
журнале (`docker compose logs backup`) нормально увидеть одно из двух:
|
|
||||||
|
|
||||||
- если `backup` успел раньше, чем `app` создал БД:
|
|
||||||
|
|
||||||
```
|
|
||||||
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
|
|
||||||
Первый бэкап не удался — следующая попытка по расписанию.
|
|
||||||
```
|
|
||||||
|
|
||||||
- если БД уже была:
|
|
||||||
|
|
||||||
```
|
|
||||||
В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными.
|
|
||||||
Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю.
|
|
||||||
```
|
|
||||||
|
|
||||||
Это защита: пустой новый Pi не перезапишет историю на VPS.
|
|
||||||
|
|
||||||
4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий **с данными**):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backup fs-backup list vps
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
|
|
||||||
Защита выше не распознаёт БД без таблиц: если первый бэкап попал ровно в момент создания
|
|
||||||
БД, в списке может появиться свежий снимок с `?` (задача #74). Такой снимок не выбирайте.
|
|
||||||
|
|
||||||
5. Восстановите, подставив ID из `list vps`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop app
|
|
||||||
docker compose exec backup fs-backup restore <ID> --repo vps --yes
|
|
||||||
docker compose start app
|
|
||||||
```
|
|
||||||
|
|
||||||
Если самый свежий снимок в `list vps` — с данными, вместо ID можно написать `latest`.
|
|
||||||
|
|
||||||
6. Проверьте сайт. Затем сделайте снимок вручную, чтобы локальная копия появилась сразу:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backup fs-backup run
|
|
||||||
docker compose exec backup fs-backup status
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что должно получиться:** на сайте прежние данные; `run` проходит (`OK: репозиторий local…`,
|
|
||||||
`OK: репозиторий vps…`); в `status` оба репозитория с сегодняшним бэкапом.
|
|
||||||
|
|
||||||
> **VPS тоже недоступен?** Восстанавливайте из последнего архива на ПК: скопируйте его на
|
|
||||||
> новый Pi и выполните [восстановление из файла-архива](#восстановление-из-файла-архива).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Повседневные действия
|
|
||||||
|
|
||||||
| Задача | На Pi (`cd ~/forbidden-stars`) | С ПК (папка репозитория) |
|
|
||||||
|---|---|---|
|
|
||||||
| Состояние бэкапов | `docker compose exec backup fs-backup status` | `.\scripts\fs-backup.ps1 status` |
|
|
||||||
| Хронология снимков | `docker compose exec backup fs-backup list` (`list vps`) | `.\scripts\fs-backup.ps1 list` (`-Repo vps`) |
|
|
||||||
| Снимок перед рискованным обновлением | `docker compose exec backup fs-backup run --tag before-update` | `.\scripts\fs-backup.ps1 now -Tag before-update` |
|
|
||||||
| Проверить целостность данных | `docker compose exec backup fs-backup verify` | `.\scripts\fs-backup.ps1 verify` |
|
|
||||||
| Скачать снимок на ПК | — | `.\scripts\fs-backup.ps1 pull` |
|
|
||||||
| Журнал контейнера | `docker compose logs --tail 100 backup` | — |
|
|
||||||
|
|
||||||
**Рекомендуемый ритм**
|
|
||||||
- **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`).
|
|
||||||
- **Раз в месяц:**
|
|
||||||
- скачать снимок на ПК (`pull`);
|
|
||||||
- проверить скачанный архив ([шаг 7](#шаг-7-проверка-скачанного-архива));
|
|
||||||
- удалить с ПК старые архивы — они не зашифрованы.
|
|
||||||
- **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что
|
|
||||||
бэкапы перестали проходить (причину покажет `fs-backup status`).
|
|
||||||
|
|
||||||
**Изменить расписание или сроки хранения.** Отредактируйте `BACKUP_*` в `.env` на Pi, затем
|
|
||||||
пересоздайте контейнер:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d backup
|
|
||||||
```
|
|
||||||
|
|
||||||
**Удалить именованный или pre-restore снимок.** Автоматически они не удаляются, удалять
|
|
||||||
нужно в обоих репозиториях:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backup fs-backup restic local forget <ID> --prune
|
|
||||||
docker compose exec backup fs-backup restic vps forget <ID-на-vps> --prune
|
|
||||||
```
|
|
||||||
|
|
||||||
ID одного и того же снимка в `local` и `vps` разные — смотрите `list` и `list vps`.
|
|
||||||
|
|
||||||
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
|
||||||
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
|
||||||
|
|
||||||
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
|
||||||
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
|
||||||
> не трогает.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Неполадки
|
|
||||||
|
|
||||||
Первое, что стоит сделать при любой проблеме:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/forbidden-stars
|
|
||||||
docker compose exec backup fs-backup status
|
|
||||||
docker compose logs --tail 100 backup
|
|
||||||
```
|
|
||||||
|
|
||||||
### На Pi (журнал и команды `fs-backup`)
|
|
||||||
|
|
||||||
| Симптом | Причина | Что сделать |
|
|
||||||
|---|---|---|
|
|
||||||
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `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` с ПК этим ключом |
|
|
||||||
| `Host key verification failed` | VPS переустановлен, у него новый ключ хоста | `docker compose exec backup rm /backup/state/known_hosts`, затем `docker compose exec backup fs-backup run` |
|
|
||||||
| `Репозиторий vps недоступен` и `Connection timed out` | VPS недоступен или неверный `BACKUP_VPS_HOST`/`PORT` | проверить VPS; локальная копия при этом продолжает делаться |
|
|
||||||
| `В БД нет ни игроков, ни партий, а последний снимок … — с данными` | новый или очищенный сервер — защита от затирания истории | новый Pi: [раздел 9](#9-катастрофа-pi-умер). Данные удалены намеренно: `fs-backup run --allow-empty` |
|
|
||||||
| `копия БД не прошла PRAGMA integrity_check` | живая БД повреждена | снимок не создаётся, старые целы. Восстановить последний хороший снимок ([раздел 8](#8-восстановление-прода)) |
|
|
||||||
| `приложение работает — восстанавливать поверх него нельзя` | не остановлен `app` | `docker compose stop app`, повторить команду |
|
|
||||||
| `найдены следы прерванного восстановления` | восстановление оборвалось (выключили питание и т.п.) | `docker compose stop app`, `docker compose exec backup fs-backup recover`, затем при необходимости повторить `restore` |
|
|
||||||
| `уже выполняется другая операция бэкапа` | идёт бэкап по расписанию или проверка | подождать: `docker compose logs -f backup` |
|
|
||||||
| `мало места в …` | диск Pi заполнен | `df -h`; удалить неиспользуемые образы (`docker image prune`); уменьшить `BACKUP_KEEP_*` |
|
|
||||||
| `снимок '…' не найден в репозитории` | опечатка в 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`, успешных бэкапов ещё не было или не задан `BACKUP_PASSWORD` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице |
|
|
||||||
|
|
||||||
**Про `recover`.** Команда сама определяет, на каком этапе оборвалось восстановление:
|
|
||||||
- если замена успела пройти во всех томах — дочищает промежуточные папки, восстановленные
|
|
||||||
данные остаются;
|
|
||||||
- иначе возвращает каждому тому прежние данные.
|
|
||||||
|
|
||||||
После неё можно спокойно запускать приложение или повторить восстановление.
|
|
||||||
|
|
||||||
**Пароль бэкапов потерян навсегда.** Прочитать существующие снимки невозможно. Начать заново
|
|
||||||
(**это удалит все старые бэкапы**):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop backup
|
|
||||||
docker compose rm -f backup
|
|
||||||
docker volume ls | grep backup-data # имя тома, обычно forbidden-stars_backup-data
|
|
||||||
docker volume rm <имя тома>
|
|
||||||
```
|
|
||||||
|
|
||||||
На VPS: `rm -rf /srv/fs-backups/restic`. Затем новый пароль в `.env`,
|
|
||||||
`docker compose up -d backup`.
|
|
||||||
|
|
||||||
### На ПК (скрипт `fs-backup.ps1`)
|
|
||||||
|
|
||||||
| Симптом | Причина | Что сделать |
|
|
||||||
|---|---|---|
|
|
||||||
| `running scripts is disabled on this system` | политика запуска PowerShell | `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` |
|
|
||||||
| `Set BACKUP_PI_SSH in .env` | не заполнен адрес Pi | шаг 6, пункт 3 |
|
|
||||||
| `ssh: connect to host … Connection timed out` | неверный IP Pi или ПК не в той сети | проверить `ssh pi@<IP-адрес-Pi>` вручную |
|
|
||||||
| Пароль Pi спрашивается несколько раз за команду | не настроен вход по ключу | шаг 6, пункт 2 |
|
|
||||||
| `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` |
|
|
||||||
| шаг 7: `integrity_check` не `ok` или счётчики не совпадают с `list` | архив повреждён или скачан не тот снимок | удалить файл из `backups\` и скачать заново (`pull -Snapshot <ID>`); если повторяется — `fs-backup verify` на Pi |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Справочник: команды и переменные
|
|
||||||
|
|
||||||
### `fs-backup` — внутри контейнера
|
|
||||||
|
|
||||||
Запуск на Pi из папки прода: `docker compose exec backup fs-backup <команда>`.
|
|
||||||
|
|
||||||
| Команда | Что делает |
|
|
||||||
|---|---|
|
|
||||||
| `status` | расписание, хранение, последний бэкап и проверка по каждому репозиторию, размеры |
|
|
||||||
| `list [local\|vps]` | хронология снимков |
|
|
||||||
| `run [--tag имя] [--allow-empty]` | снимок сейчас; `--tag` — именованный (не удаляется) |
|
|
||||||
| `verify` | проверка данных репозиториев и открываемости БД последнего снимка |
|
|
||||||
| `restore <ID\|latest> [--repo local\|vps] --yes` | восстановление снимка (приложение должно быть остановлено) |
|
|
||||||
| `import /import/<файл> --yes` | восстановление из `.tar` или старого `.tar.gz` |
|
|
||||||
| `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` |
|
|
||||||
| `help` | краткая справка |
|
|
||||||
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
|
||||||
|
|
||||||
Без `--yes` команды `restore` и `import` только показывают, что собираются сделать.
|
|
||||||
Флаг `--no-pre-restore` у `restore`/`import` пропускает страховочный снимок — используйте,
|
|
||||||
только если текущие данные точно не нужны.
|
|
||||||
|
|
||||||
### Скрипт ПК
|
|
||||||
|
|
||||||
`.\scripts\fs-backup.ps1` (Windows) или `scripts/fs-backup.sh` (Linux/macOS/Git Bash):
|
|
||||||
|
|
||||||
| Команда | Что делает |
|
|
||||||
|---|---|
|
|
||||||
| `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК |
|
|
||||||
| `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 |
|
|
||||||
|
|
||||||
В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`,
|
|
||||||
`pull <ID> --repo vps`.
|
|
||||||
|
|
||||||
### Переменные `.env`
|
|
||||||
|
|
||||||
| Переменная | Где | По умолчанию | Назначение |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `BACKUP_PASSWORD` | Pi | — | пароль шифрования; пусто = бэкапы выключены |
|
|
||||||
| `BACKUP_SCHEDULE` | Pi | `0 4 * * *` | расписание бэкапа (cron) |
|
|
||||||
| `BACKUP_VERIFY_SCHEDULE` | Pi | `30 5 * * 0` | расписание проверки данных |
|
|
||||||
| `BACKUP_TZ` | Pi | `Europe/Moscow` | часовой пояс |
|
|
||||||
| `BACKUP_KEEP_DAILY` / `WEEKLY` / `MONTHLY` | Pi | `14` / `8` / `12` | глубина хранения |
|
|
||||||
| `BACKUP_COMPRESSION` | Pi | `max` | сжатие restic: `auto` / `max` / `off` |
|
|
||||||
| `BACKUP_MAX_AGE_HOURS` | Pi | `30` | порог `unhealthy` |
|
|
||||||
| `BACKUP_VPS_HOST` | Pi | — | адрес VPS; пусто = без оффсайт-копии |
|
|
||||||
| `BACKUP_VPS_USER` | Pi | `fsbackup` | SFTP-пользователь |
|
|
||||||
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
|
||||||
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
|
||||||
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
|
||||||
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
|
||||||
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
|
||||||
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
|
||||||
|
|
||||||
У скрипта ПК переменная окружения с тем же именем важнее значения из `.env`.
|
|
||||||
|
|
||||||
### Где что лежит
|
|
||||||
|
|
||||||
| Что | Где |
|
|
||||||
|---|---|
|
|
||||||
| Локальный репозиторий | том `backup-data` на Pi → `/backup/repo` в контейнере |
|
|
||||||
| Состояние (последние запуски, ключ хоста VPS) | том `backup-data` → `/backup/state` |
|
|
||||||
| Репозиторий на VPS | `/srv/fs-backups/restic` (пользователь `fsbackup`) |
|
|
||||||
| Архивы на ПК | `backups\` в папке репозитория (в git не попадают) |
|
|
||||||
| Ключ для VPS на ПК | `deploy\backup\id_backup` (в git не попадает) |
|
|
||||||
| Код | `deploy/backup/` (образ, `fs-backup.sh`), `scripts/fs-backup.ps1` / `.sh` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Итоговый чек-лист
|
|
||||||
|
|
||||||
**Настройка**
|
|
||||||
- [ ] Пароль бэкапов сохранён в менеджере паролей **и** на бумаге (шаг 1)
|
|
||||||
- [ ] Копия `.env` с Pi сохранена в менеджере паролей (шаг 1, пункт 4)
|
|
||||||
- [ ] Ключ `deploy\backup\id_backup` создан, в git не попадает (шаг 2)
|
|
||||||
- [ ] На VPS пользователь `fsbackup`, `sftp` работает, shell закрыт (шаг 3)
|
|
||||||
- [ ] Образы опубликованы, `forbidden-stars-backup` есть под arm64 (шаг 4)
|
|
||||||
- [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5)
|
|
||||||
- [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5)
|
|
||||||
- [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6)
|
|
||||||
- [ ] Скачанный архив проверен: БД целая, числа совпадают с `list` (шаг 7)
|
|
||||||
|
|
||||||
**Через сутки**
|
|
||||||
- [ ] В `list` появился снимок с меткой `scheduled` в 04:00
|
|
||||||
- [ ] В `list vps` — такой же
|
|
||||||
|
|
||||||
**Через неделю**
|
|
||||||
- [ ] В `status` есть строка `Последняя проверка данных: … данные целы`
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Точка входа контейнера backup: первый снимок (если снимков ещё нет) и расписание.
|
|
||||||
# Расписание — supercronic по BACKUP_SCHEDULE (бэкап) и BACKUP_VERIFY_SCHEDULE (проверка).
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
|
||||||
|
|
||||||
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
|
||||||
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
|
||||||
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
|
||||||
log "BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ."
|
|
||||||
log "Настройка по шагам: deploy/backup/README.md. После правки .env: docker compose up -d backup"
|
|
||||||
exec tail -f /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p /tmp/fs-backup
|
|
||||||
CRONTAB=/tmp/fs-backup/crontab
|
|
||||||
: > "$CRONTAB"
|
|
||||||
if [ -n "${BACKUP_SCHEDULE:-}" ]; then
|
|
||||||
echo "${BACKUP_SCHEDULE} fs-backup run --scheduled" >> "$CRONTAB"
|
|
||||||
fi
|
|
||||||
if [ -n "${BACKUP_VERIFY_SCHEDULE:-}" ]; then
|
|
||||||
echo "${BACKUP_VERIFY_SCHEDULE} fs-backup verify" >> "$CRONTAB"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -s "$CRONTAB" ]; then
|
|
||||||
log "Расписание отключено — только ручной запуск: docker compose exec backup fs-backup run"
|
|
||||||
exec tail -f /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! fs-backup has-snapshots; then
|
|
||||||
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу."
|
|
||||||
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Расписание (TZ=${TZ:-UTC}):"
|
|
||||||
sed 's/^/[backup] /' "$CRONTAB"
|
|
||||||
exec supercronic -passthrough-logs "$CRONTAB"
|
|
||||||
@@ -1,838 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# fs-backup — бэкапы Forbidden Stars на restic (работает внутри контейнера backup).
|
|
||||||
#
|
|
||||||
# docker compose exec backup fs-backup help
|
|
||||||
#
|
|
||||||
# Раскладка снимка: /fs/forbidden_stars.db (консистентная копия БД), /fs/uploads/,
|
|
||||||
# /fs/achievements/ — та же, что у архивов fs_*.tar.gz старого scripts/backup.sh.
|
|
||||||
# Репозитории: local — том backup-data (/backup/repo), vps — SFTP на VPS (если задан).
|
|
||||||
# Подробная инструкция: deploy/backup/README.md.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
# ─── Пути ─────────────────────────────────────────────────────────────────────
|
|
||||||
DB_VOLUME=/fs-db # том db-data: живая БД приложения
|
|
||||||
DB_NAME=forbidden_stars.db
|
|
||||||
SNAP_ROOT=/fs # корень снимка
|
|
||||||
DATA_DIRS="uploads achievements" # тома, смонтированные в $SNAP_ROOT/<имя>
|
|
||||||
LOCAL_REPO=/backup/repo
|
|
||||||
STATE_DIR=/backup/state # статусы запусков, known_hosts VPS, lock
|
|
||||||
RUNTIME_DIR=/tmp/fs-backup
|
|
||||||
NEW=.restore-new # промежуточная директория восстановления (внутри тома)
|
|
||||||
OLD=.restore-old # текущие данные на время замены
|
|
||||||
UNPACK=.restore-unpack # распаковка архива при import
|
|
||||||
PHASE=.restore-phase # фаза замены в томе (для recover после обрыва)
|
|
||||||
|
|
||||||
# ─── Настройки (из environment compose) ───────────────────────────────────────
|
|
||||||
SNAP_HOST="${BACKUP_HOSTNAME:-fs-prod}"
|
|
||||||
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
|
|
||||||
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
|
|
||||||
KEEP_MONTHLY="${BACKUP_KEEP_MONTHLY:-12}"
|
|
||||||
VERIFY_SUBSET="${BACKUP_VERIFY_SUBSET:-10%}"
|
|
||||||
MAX_AGE_HOURS="${BACKUP_MAX_AGE_HOURS:-30}"
|
|
||||||
VPS_DIR="${BACKUP_VPS_DIR:-/srv/fs-backups/restic}"
|
|
||||||
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}"
|
|
||||||
|
|
||||||
# ─── Общие функции ────────────────────────────────────────────────────────────
|
|
||||||
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
|
||||||
die() { log "ОШИБКА: $*"; exit 1; }
|
|
||||||
|
|
||||||
require_password() {
|
|
||||||
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
|
||||||
}
|
|
||||||
|
|
||||||
vps_enabled() { [ -n "${BACKUP_VPS_HOST:-}" ]; }
|
|
||||||
|
|
||||||
repos() { if vps_enabled; then echo "local vps"; else echo "local"; fi; }
|
|
||||||
|
|
||||||
repo_url() {
|
|
||||||
case "$1" in
|
|
||||||
local) echo "$LOCAL_REPO" ;;
|
|
||||||
vps) echo "sftp:fs-vps:$VPS_DIR" ;;
|
|
||||||
*) die "неизвестный репозиторий '$1' (local или vps)" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
check_repo_name() {
|
|
||||||
case "$1" in
|
|
||||||
local) ;;
|
|
||||||
vps) vps_enabled || die "VPS не настроен: BACKUP_VPS_HOST в .env пуст." ;;
|
|
||||||
*) die "неизвестный репозиторий '$1' (local или vps)" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# r <local|vps> <аргументы restic…>
|
|
||||||
r() {
|
|
||||||
_url="$(repo_url "$1")"
|
|
||||||
shift
|
|
||||||
restic -r "$_url" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# SSH-доступ к VPS для restic (sftp:fs-vps:…): ключ из BACKUP_SSH_KEY_B64, known_hosts
|
|
||||||
# хранится в томе — ключ хоста VPS запоминается при первом подключении.
|
|
||||||
setup_ssh() {
|
|
||||||
vps_enabled || return 0
|
|
||||||
[ -n "${BACKUP_SSH_KEY_B64:-}" ] || die "BACKUP_VPS_HOST задан, а BACKUP_SSH_KEY_B64 пуст — нечем входить на VPS."
|
|
||||||
mkdir -p "$HOME/.ssh" "$RUNTIME_DIR"
|
|
||||||
chmod 700 "$HOME/.ssh" "$RUNTIME_DIR"
|
|
||||||
printf '%s' "$BACKUP_SSH_KEY_B64" | tr -d ' \r\n\t' | base64 -d > "$RUNTIME_DIR/id_backup" 2>/dev/null \
|
|
||||||
|| die "BACKUP_SSH_KEY_B64 не декодируется из base64 — скопируйте строку заново (README, шаг 2)."
|
|
||||||
chmod 600 "$RUNTIME_DIR/id_backup"
|
|
||||||
grep -q 'PRIVATE KEY' "$RUNTIME_DIR/id_backup" \
|
|
||||||
|| die "BACKUP_SSH_KEY_B64 — не приватный SSH-ключ (закодирован .pub вместо приватного?)."
|
|
||||||
cat > "$HOME/.ssh/config" <<EOF
|
|
||||||
Host fs-vps
|
|
||||||
HostName ${BACKUP_VPS_HOST}
|
|
||||||
User ${BACKUP_VPS_USER:-fsbackup}
|
|
||||||
Port ${BACKUP_VPS_PORT:-22}
|
|
||||||
IdentityFile $RUNTIME_DIR/id_backup
|
|
||||||
IdentitiesOnly yes
|
|
||||||
BatchMode yes
|
|
||||||
StrictHostKeyChecking accept-new
|
|
||||||
UserKnownHostsFile $STATE_DIR/known_hosts
|
|
||||||
ServerAliveInterval 30
|
|
||||||
ServerAliveCountMax 6
|
|
||||||
EOF
|
|
||||||
chmod 600 "$HOME/.ssh/config"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Одна операция за раз (расписание и ручные команды не должны пересекаться).
|
|
||||||
take_lock() {
|
|
||||||
exec 9>"$STATE_DIR/lock"
|
|
||||||
flock -n 9 || die "уже выполняется другая операция бэкапа — дождитесь окончания (docker compose logs -f backup)."
|
|
||||||
}
|
|
||||||
|
|
||||||
mark() { # mark <имя> ok|err <текст>
|
|
||||||
printf '%s\t%s\n' "$(date +%s)" "$3" > "$STATE_DIR/$1.$2"
|
|
||||||
if [ "$2" = ok ]; then rm -f "$STATE_DIR/$1.err"; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
human() { # байты → «12.3 MB»
|
|
||||||
awk -v b="${1:-0}" 'BEGIN { split("B KB MB GB TB", u, " "); i = 1;
|
|
||||||
while (b >= 1024 && i < 5) { b /= 1024; i++ }
|
|
||||||
printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
|
||||||
|
|
||||||
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
|
||||||
db_ok() {
|
|
||||||
[ -s "$1" ] || return 1
|
|
||||||
[ "$(sqlite3 "file:$1?immutable=1" 'PRAGMA integrity_check;' 2>&1)" = "ok" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
db_counts() { # → «игроков|партий»
|
|
||||||
sqlite3 "file:$1?immutable=1" \
|
|
||||||
"SELECT (SELECT count(*) FROM users WHERE role='player'), (SELECT count(*) FROM matches);" 2>/dev/null \
|
|
||||||
|| echo "?|?"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Консистентная копия живой БД в $SNAP_ROOT (VACUUM INTO — один снимок-транзакция,
|
|
||||||
# приложению не мешает: в WAL читатели не блокируют писателей).
|
|
||||||
stage_db() {
|
|
||||||
[ -f "$DB_VOLUME/$DB_NAME" ] || die "БД $DB_VOLUME/$DB_NAME не найдена — приложение ещё ни разу не запускалось?"
|
|
||||||
rm -f "$SNAP_ROOT/$DB_NAME"
|
|
||||||
sqlite3 -cmd '.timeout 30000' "$DB_VOLUME/$DB_NAME" "VACUUM INTO '$SNAP_ROOT/$DB_NAME';" >&2 \
|
|
||||||
|| die "не удалось снять копию БД."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Создать репозиторий, если его ещё нет (формат v2 — со сжатием).
|
|
||||||
ensure_repo() {
|
|
||||||
set +e
|
|
||||||
r "$1" cat config > /dev/null 2> "$RUNTIME_DIR/cat.err"
|
|
||||||
_rc=$?
|
|
||||||
set -e
|
|
||||||
case "$_rc" in
|
|
||||||
0) return 0 ;;
|
|
||||||
10)
|
|
||||||
log "Репозиторий $1 ещё не создан — создаю ($(repo_url "$1"), формат v2 со сжатием)…"
|
|
||||||
r "$1" init --repository-version 2 >&2
|
|
||||||
;;
|
|
||||||
12) die "неверный BACKUP_PASSWORD для репозитория $1 (пароль отличается от того, с которым он создан)." ;;
|
|
||||||
*) cat "$RUNTIME_DIR/cat.err" >&2; log "Репозиторий $1 недоступен (код restic $_rc)."; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
backup_to() { # backup_to <repo> [--tag …]
|
|
||||||
_repo="$1"
|
|
||||||
shift
|
|
||||||
ensure_repo "$_repo" || return 1
|
|
||||||
r "$_repo" backup --host "$SNAP_HOST" \
|
|
||||||
--exclude "$NEW" --exclude "$OLD" --exclude "$UNPACK" --exclude "$PHASE" \
|
|
||||||
"$@" "$SNAP_ROOT" >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
# Именованные снимки (run --tag …, метка keep) и страховочные (pre-restore) политика не
|
|
||||||
# удаляет — только вручную: fs-backup restic <repo> forget <id> --prune. Три последних
|
|
||||||
# снимка остаются всегда (keep-daily иначе заменил бы более ранний снимок того же дня).
|
|
||||||
forget_repo() {
|
|
||||||
log "Очистка по политике: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. (+ 3 последних, keep и pre-restore)…"
|
|
||||||
r "$1" forget --host "$SNAP_HOST" --group-by host --keep-last 3 \
|
|
||||||
--keep-daily "$KEEP_DAILY" --keep-weekly "$KEEP_WEEKLY" --keep-monthly "$KEEP_MONTHLY" \
|
|
||||||
--keep-tag keep --keep-tag pre-restore --prune >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
|
||||||
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
|
||||||
guard_empty() { # guard_empty <игроков> <партий>
|
|
||||||
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 \
|
|
||||||
'[.[0].tags[]? | select(startswith("players:") or startswith("matches:"))
|
|
||||||
| ltrimstr("players:") | ltrimstr("matches:") | (tonumber? // 0)] | add // 0')" || _prev=0
|
|
||||||
if [ "${_prev:-0}" -gt 0 ]; then
|
|
||||||
for _r in $(repos); do mark "run-$_r" err "БД пуста, а в репозитории есть данные — бэкап не сделан (см. README, «Катастрофа»)"; done
|
|
||||||
log "В БД нет ни игроков, ни партий, а последний снимок в репозитории $_repo — с данными."
|
|
||||||
log "Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю."
|
|
||||||
log "Восстановите данные (deploy/backup/README.md, «Катастрофа»). Если БД очищена намеренно:"
|
|
||||||
die "fs-backup run --allow-empty"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
latest_short_id() {
|
|
||||||
r "$1" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r '.[0].short_id // empty'
|
|
||||||
}
|
|
||||||
|
|
||||||
# resolve_snapshot <repo> <id|latest> → полный id (или die)
|
|
||||||
resolve_snapshot() {
|
|
||||||
if [ "$2" = latest ]; then
|
|
||||||
_json="$(r "$1" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null)" || _json="[]"
|
|
||||||
else
|
|
||||||
_json="$(r "$1" snapshots "$2" --json 2>/dev/null)" || _json="[]"
|
|
||||||
fi
|
|
||||||
_id="$(printf '%s' "$_json" | jq -r '.[0].id // empty')"
|
|
||||||
[ -n "$_id" ] || die "снимок '$2' не найден в репозитории $1 (список: fs-backup list $1)."
|
|
||||||
echo "$_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
warn_leftovers() {
|
|
||||||
_vols="$(interrupted_volumes)"
|
|
||||||
if [ -n "$_vols" ]; then
|
|
||||||
log "ВНИМАНИЕ: восстановление было прервано (тома: ${_vols% }). Данные могут быть смешанными."
|
|
||||||
log " Остановите app и выполните: fs-backup recover (README, раздел «Неполадки»)."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── run ──────────────────────────────────────────────────────────────────────
|
|
||||||
cmd_run() {
|
|
||||||
_kind=manual
|
|
||||||
_tag=""
|
|
||||||
_allow_empty=no
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--scheduled) _kind=scheduled ;;
|
|
||||||
--allow-empty) _allow_empty=yes ;;
|
|
||||||
--tag)
|
|
||||||
[ $# -ge 2 ] || die "--tag требует значение"
|
|
||||||
shift
|
|
||||||
case "$1" in *[!A-Za-z0-9._-]*|'') die "метка может содержать только латиницу, цифры, '.', '_', '-'" ;; esac
|
|
||||||
_tag="$1"
|
|
||||||
;;
|
|
||||||
*) die "run: неизвестный аргумент '$1'" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
take_lock
|
|
||||||
warn_leftovers
|
|
||||||
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
|
|
||||||
|
|
||||||
log "Снимок данных: консистентная копия БД…"
|
|
||||||
stage_db
|
|
||||||
db_ok "$SNAP_ROOT/$DB_NAME" \
|
|
||||||
|| die "копия БД не прошла PRAGMA integrity_check — снимок НЕ создан, старые снимки не тронуты."
|
|
||||||
_counts="$(db_counts "$SNAP_ROOT/$DB_NAME")"
|
|
||||||
_players="${_counts%%|*}"
|
|
||||||
_matches="${_counts##*|}"
|
|
||||||
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"} \
|
|
||||||
&& forget_repo "$_repo" \
|
|
||||||
&& r "$_repo" check >&2; then
|
|
||||||
_sid="$(latest_short_id "$_repo")"
|
|
||||||
mark "run-$_repo" ok "снимок $_sid"
|
|
||||||
log "OK: репозиторий $_repo, снимок $_sid."
|
|
||||||
else
|
|
||||||
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
|
||||||
_failed="$_failed $_repo"
|
|
||||||
log "ОШИБКА в репозитории $_repo — остальные репозитории продолжаю."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ -z "$_failed" ] || die "бэкап не удался в:$_failed"
|
|
||||||
log "Бэкап завершён."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── list ─────────────────────────────────────────────────────────────────────
|
|
||||||
cmd_list() {
|
|
||||||
_repo="${1:-local}"
|
|
||||||
check_repo_name "$_repo"
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
_json="$(r "$_repo" snapshots --json)" || die "не удалось прочитать репозиторий $_repo."
|
|
||||||
_n="$(printf '%s' "$_json" | jq 'length')"
|
|
||||||
echo "Снимки в репозитории $_repo ($(repo_url "$_repo")): $_n шт., время — ${TZ:-UTC}"
|
|
||||||
[ "$_n" -gt 0 ] || { echo " (пусто)"; return 0; }
|
|
||||||
echo "ID Время Игроков Партий Размер Прирост Метки"
|
|
||||||
printf '%s' "$_json" | jq -r '
|
|
||||||
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
|
|
||||||
sort_by(.time) | .[] | [
|
|
||||||
.short_id,
|
|
||||||
(.time[0:16] | sub("T"; " ")),
|
|
||||||
tagval("players:"),
|
|
||||||
tagval("matches:"),
|
|
||||||
(.summary.total_bytes_processed // 0),
|
|
||||||
(.summary.data_added_packed // .summary.data_added // 0),
|
|
||||||
([.tags[]? | select((startswith("players:") or startswith("matches:")) | not)] | join(","))
|
|
||||||
] | @tsv' |
|
|
||||||
awk -F '\t' '
|
|
||||||
function h(b, u, i) { split("B KB MB GB TB", u, " "); i = 1;
|
|
||||||
while (b >= 1024 && i < 5) { b /= 1024; i++ }
|
|
||||||
return sprintf(i == 1 ? "%d %s" : "%.1f %s", b, u[i]) }
|
|
||||||
{ printf "%-9s %-17s %7s %6s %9s %9s %s\n", $1, $2, $3, $4, h($5), h($6), $7 }'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── status ───────────────────────────────────────────────────────────────────
|
|
||||||
print_mark() { # print_mark <подпись> <файл-без-суффикса>
|
|
||||||
if [ -f "$2.ok" ]; then
|
|
||||||
echo " $1 $(fmt_epoch "$(cut -f1 "$2.ok")") — $(cut -f2- "$2.ok")"
|
|
||||||
else
|
|
||||||
echo " $1 ещё не было"
|
|
||||||
fi
|
|
||||||
if [ -f "$2.err" ]; then
|
|
||||||
echo " последняя ОШИБКА: $(fmt_epoch "$(cut -f1 "$2.err")") — $(cut -f2- "$2.err")"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_status() {
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
echo "Бэкапы Forbidden Stars"
|
|
||||||
echo " Расписание бэкапа: ${BACKUP_SCHEDULE:-отключено}"
|
|
||||||
echo " Расписание проверки: ${BACKUP_VERIFY_SCHEDULE:-отключено}"
|
|
||||||
echo " Часовой пояс: ${TZ:-UTC}"
|
|
||||||
echo " Хранение: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. + 3 последних, именованные (keep) и pre-restore"
|
|
||||||
echo " Сжатие restic: $RESTIC_COMPRESSION"
|
|
||||||
echo " Имя хоста в снимках: $SNAP_HOST"
|
|
||||||
vps_enabled || echo " VPS: не настроен (только локальная копия)"
|
|
||||||
for _repo in $(repos); do
|
|
||||||
echo
|
|
||||||
echo "[$_repo] $(repo_url "$_repo")"
|
|
||||||
print_mark "Последний бэкап:" "$STATE_DIR/run-$_repo"
|
|
||||||
print_mark "Последняя проверка данных:" "$STATE_DIR/verify-$_repo"
|
|
||||||
if _stats="$(r "$_repo" stats --mode raw-data --json 2>/dev/null)"; then
|
|
||||||
printf '%s' "$_stats" | jq -r '"\(.snapshots_count)\t\(.total_size)\t\(.total_uncompressed_size // .total_size)\t\(.compression_space_saving // 0 | floor)"' |
|
|
||||||
while IFS="$(printf '\t')" read -r _cnt _size _raw _saving; do
|
|
||||||
echo " Снимков: $_cnt"
|
|
||||||
echo " Размер репозитория: $(human "$_size") (без сжатия $(human "$_raw"), экономия ${_saving}%)"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo " Репозиторий: недоступен или ещё не создан"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
warn_leftovers
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── health (healthcheck) ─────────────────────────────────────────────────────
|
|
||||||
cmd_health() {
|
|
||||||
[ -n "$RESTIC_PASSWORD" ] || { echo "BACKUP_PASSWORD не задан — бэкапы отключены"; exit 1; }
|
|
||||||
[ -n "${BACKUP_SCHEDULE:-}" ] || { echo "расписание отключено"; exit 0; }
|
|
||||||
_now="$(date +%s)"
|
|
||||||
for _repo in $(repos); do
|
|
||||||
[ -f "$STATE_DIR/run-$_repo.ok" ] || { echo "$_repo: успешных бэкапов ещё не было"; exit 1; }
|
|
||||||
_ts="$(cut -f1 "$STATE_DIR/run-$_repo.ok")"
|
|
||||||
if [ $((_now - _ts)) -gt $((MAX_AGE_HOURS * 3600)) ]; then
|
|
||||||
echo "$_repo: последний успешный бэкап старше $MAX_AGE_HOURS ч"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo ok
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_has_snapshots() {
|
|
||||||
[ -n "$RESTIC_PASSWORD" ] || exit 1
|
|
||||||
_n="$(r local snapshots --json 2>/dev/null | jq 'length' 2>/dev/null)" || exit 1
|
|
||||||
[ "${_n:-0}" -gt 0 ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── verify ───────────────────────────────────────────────────────────────────
|
|
||||||
cmd_verify() {
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
take_lock
|
|
||||||
_failed=""
|
|
||||||
for _repo in $(repos); do
|
|
||||||
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
|
||||||
_tmp="$RUNTIME_DIR/verify.db"
|
|
||||||
rm -f "$_tmp"
|
|
||||||
if r "$_repo" check --read-data-subset="$VERIFY_SUBSET" >&2 \
|
|
||||||
&& r "$_repo" dump --host "$SNAP_HOST" latest "$SNAP_ROOT/$DB_NAME" > "$_tmp" \
|
|
||||||
&& db_ok "$_tmp"; then
|
|
||||||
_counts="$(db_counts "$_tmp")"
|
|
||||||
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
|
||||||
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
|
||||||
else
|
|
||||||
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
|
||||||
_failed="$_failed $_repo"
|
|
||||||
log "ОШИБКА проверки репозитория $_repo."
|
|
||||||
fi
|
|
||||||
rm -f "$_tmp"
|
|
||||||
done
|
|
||||||
[ -z "$_failed" ] || die "проверка не прошла в:$_failed"
|
|
||||||
log "Проверка завершена."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── export ───────────────────────────────────────────────────────────────────
|
|
||||||
cmd_export() {
|
|
||||||
_snap=""
|
|
||||||
_repo=local
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;;
|
|
||||||
-*) die "export: неизвестный аргумент '$1'" ;;
|
|
||||||
*) _snap="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
check_repo_name "$_repo"
|
|
||||||
[ ! -t 1 ] || die "export пишет tar в stdout — перенаправьте в файл: docker compose exec -T backup fs-backup export latest > fs.tar"
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
_id="$(resolve_snapshot "$_repo" "${_snap:-latest}")"
|
|
||||||
log "Экспорт снимка ${_id%"${_id#????????}"} из $_repo (tar без сжатия)…"
|
|
||||||
r "$_repo" dump --archive tar "$_id:$SNAP_ROOT" /
|
|
||||||
log "Экспорт завершён."
|
|
||||||
}
|
|
||||||
|
|
||||||
# info <id|latest> [--repo …] → «short_id<TAB>YYYYmmdd_HHMM» (для скриптов на ПК)
|
|
||||||
cmd_info() {
|
|
||||||
_snap="latest"
|
|
||||||
_repo=local
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;;
|
|
||||||
*) _snap="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
check_repo_name "$_repo"
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
_id="$(resolve_snapshot "$_repo" "$_snap")"
|
|
||||||
r "$_repo" snapshots "$_id" --json | jq -r '.[0] | "\(.short_id)\t\(.time[0:16] | gsub("[-:]"; "") | sub("T"; "_"))"'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── restore / import: общая часть ───────────────────────────────────────────
|
|
||||||
# Отказ, если приложение запущено: любой HTTP-ответ от app:8000 (даже ошибка) значит, что
|
|
||||||
# uvicorn жив. Остановленный контейнер не резолвится в сети compose — ответа не будет.
|
|
||||||
ensure_app_stopped() {
|
|
||||||
if wget -S -T 3 -O /dev/null "http://$APP_HOST:8000/api/health" 2>&1 | grep -q 'HTTP/'; then
|
|
||||||
die "приложение работает — восстанавливать поверх него нельзя. Сначала: docker compose stop app"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_space() { # check_space <нужно байт>
|
|
||||||
_need_kb=$(( ${1:-0} * 11 / 10 / 1024 + 51200 )) # +10% и 50 МБ запаса
|
|
||||||
for _d in "$DB_VOLUME" "$SNAP_ROOT/uploads" "$SNAP_ROOT/achievements"; do
|
|
||||||
_avail="$(df -Pk "$_d" | awk 'NR == 2 { print $4 }')"
|
|
||||||
[ "${_avail:-0}" -ge "$_need_kb" ] \
|
|
||||||
|| die "мало места в $_d: свободно $(human $((_avail * 1024))), нужно ~$(human $((_need_kb * 1024)))."
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
clean_staging() {
|
|
||||||
for _p in "${DB_VOLUME:?}/$NEW" "${SNAP_ROOT:?}/uploads/$UNPACK" "$SNAP_ROOT/uploads/$NEW" "$SNAP_ROOT/achievements/$NEW"; do
|
|
||||||
[ -e "$_p" ] || continue
|
|
||||||
chmod -R u+rwx "$_p" 2>/dev/null || true # каталоги без права записи иначе не удалить
|
|
||||||
rm -rf "$_p"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare_staging() {
|
|
||||||
clean_staging
|
|
||||||
mkdir -p "$DB_VOLUME/$NEW"
|
|
||||||
for _d in $DATA_DIRS; do mkdir -p "$SNAP_ROOT/$_d/$NEW"; done
|
|
||||||
}
|
|
||||||
|
|
||||||
count_files() { find "$1" -type f | wc -l | tr -d ' '; }
|
|
||||||
|
|
||||||
# Шаг 3: проверка развёрнутых данных. verify_staging <файлов uploads> <файлов achievements>
|
|
||||||
verify_staging() {
|
|
||||||
log "Проверка развёрнутых данных…"
|
|
||||||
_db="$DB_VOLUME/$NEW/$DB_NAME"
|
|
||||||
[ -s "$_db" ] || die "в восстанавливаемых данных нет БД — текущие данные НЕ тронуты."
|
|
||||||
db_ok "$_db" || die "восстановленная БД не прошла PRAGMA integrity_check — текущие данные НЕ тронуты."
|
|
||||||
_tables="$(sqlite3 "file:$_db?immutable=1" \
|
|
||||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('users','matches','alembic_version');")"
|
|
||||||
[ "$_tables" = 3 ] || die "в восстановленной БД нет ключевых таблиц — текущие данные НЕ тронуты."
|
|
||||||
set -- "$1" "$2"
|
|
||||||
for _d in $DATA_DIRS; do
|
|
||||||
_got="$(count_files "$SNAP_ROOT/$_d/$NEW")"
|
|
||||||
[ "$_got" -eq "$1" ] || die "$_d: развёрнуто $_got файлов, ожидалось $1 — текущие данные НЕ тронуты."
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
_counts="$(db_counts "$_db")"
|
|
||||||
log "Развёрнутые данные в порядке: игроков ${_counts%%|*}, партий ${_counts##*|}."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Шаг 4: страховочный снимок текущих данных в локальный репозиторий.
|
|
||||||
pre_restore_snapshot() {
|
|
||||||
_has_files="$(find "$SNAP_ROOT/uploads" "$SNAP_ROOT/achievements" -mindepth 1 -maxdepth 1 \
|
|
||||||
! -name "$NEW" ! -name "$OLD" ! -name "$UNPACK" | head -n 1)"
|
|
||||||
if [ ! -f "$DB_VOLUME/$DB_NAME" ] && [ -z "$_has_files" ]; then
|
|
||||||
log "Текущих данных нет — страховочный снимок не нужен."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
log "Страховочный снимок текущих данных (метка pre-restore)…"
|
|
||||||
rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal"
|
|
||||||
if [ -f "$DB_VOLUME/$DB_NAME" ]; then
|
|
||||||
if ! sqlite3 -cmd '.timeout 30000' "$DB_VOLUME/$DB_NAME" "VACUUM INTO '$SNAP_ROOT/$DB_NAME';" 2>/dev/null; then
|
|
||||||
log "Текущая БД не читается штатно — сохраняю её файлы как есть."
|
|
||||||
rm -f "$SNAP_ROOT/$DB_NAME"
|
|
||||||
cp "$DB_VOLUME/$DB_NAME" "$SNAP_ROOT/$DB_NAME"
|
|
||||||
if [ -f "$DB_VOLUME/$DB_NAME-wal" ]; then cp "$DB_VOLUME/$DB_NAME-wal" "$SNAP_ROOT/$DB_NAME-wal"; fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
_counts="$(db_counts "$SNAP_ROOT/$DB_NAME")"
|
|
||||||
if ! backup_to local --tag pre-restore --tag "players:${_counts%%|*}" --tag "matches:${_counts##*|}"; then
|
|
||||||
rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal"
|
|
||||||
die "страховочный снимок не удался — текущие данные НЕ тронуты."
|
|
||||||
fi
|
|
||||||
rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal"
|
|
||||||
log "Страховочный снимок: $(latest_short_id local)."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Элементы тома, участвующие в замене. Для БД — только файлы SQLite (в томе db-data
|
|
||||||
# лежат ещё точки монтирования uploads/achievements приложения — их не трогаем).
|
|
||||||
move_items() { # move_items <из> <в> <all|db>
|
|
||||||
if [ "$3" = db ]; then
|
|
||||||
for _f in "$DB_NAME" "$DB_NAME-wal" "$DB_NAME-shm" "$DB_NAME-journal"; do
|
|
||||||
if [ -e "$1/$_f" ]; then mv "$1/$_f" "$2/" || return 1; fi
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
for _item in "$1"/* "$1"/.[!.]* "$1"/..?*; do
|
|
||||||
[ -e "$_item" ] || [ -L "$_item" ] || continue
|
|
||||||
case "${_item##*/}" in "$NEW"|"$OLD"|"$UNPACK"|"$PHASE") continue ;; esac
|
|
||||||
mv "$_item" "$2/" || return 1
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
volume_path() { if [ "$1" = db ]; then echo "$DB_VOLUME"; else echo "$SNAP_ROOT/$1"; fi; }
|
|
||||||
volume_mode() { if [ "$1" = db ]; then echo db; else echo all; fi; }
|
|
||||||
|
|
||||||
# Замена в одном томе в две фазы; файл фазы ($PHASE) переживает обрыв питания, по нему
|
|
||||||
# fs-backup recover понимает, как вернуть данные.
|
|
||||||
# old — фаза A: текущие данные переносятся в .restore-old (новые ещё не тронуты)
|
|
||||||
# new — фаза B: новые данные переносятся из .restore-new на место (старые целиком в .restore-old)
|
|
||||||
# swapped — том заменён; committed — заменены все тома, идёт уборка .restore-old
|
|
||||||
# Код возврата: 0 — заменено, 1 — сбой в фазе A, 2 — сбой в фазе B.
|
|
||||||
swap_in() { # swap_in <том> <all|db>
|
|
||||||
echo old > "$1/$PHASE" || return 1
|
|
||||||
mkdir -p "$1/$OLD" || return 1
|
|
||||||
move_items "$1" "$1/$OLD" "$2" || return 1
|
|
||||||
echo new > "$1/$PHASE" || return 1
|
|
||||||
move_items "$1/$NEW" "$1" "$2" || return 2
|
|
||||||
rm -rf "${1:?}/$NEW"
|
|
||||||
echo swapped > "$1/$PHASE" || return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
# Вернуть тому прежнее содержимое. <1|2> — фаза сбоя: в фазе B (и для уже заменённого тома)
|
|
||||||
# сначала убрать новые данные обратно в .restore-new, в фазе A на месте лежат только старые.
|
|
||||||
swap_back() { # swap_back <том> <all|db> <1|2>
|
|
||||||
if [ "$3" = 2 ]; then
|
|
||||||
mkdir -p "$1/$NEW"
|
|
||||||
move_items "$1" "$1/$NEW" "$2" || true
|
|
||||||
fi
|
|
||||||
move_items "$1/$OLD" "$1" "$2" || true
|
|
||||||
if rmdir "$1/$OLD" 2>/dev/null || [ ! -d "$1/$OLD" ]; then
|
|
||||||
rm -f "$1/$PHASE"
|
|
||||||
else
|
|
||||||
log "ВНИМАНИЕ: в $1/$OLD остались файлы, которые не удалось вернуть — перенесите их вручную."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Шаги 4–6: страховка, замена через rename в пределах тома, уборка.
|
|
||||||
apply_staging() { # apply_staging <skip_safety yes|no>
|
|
||||||
if [ "$1" = no ]; then pre_restore_snapshot; fi
|
|
||||||
log "Замена данных (rename в пределах каждого тома)…"
|
|
||||||
_done=""
|
|
||||||
for _v in db $DATA_DIRS; do
|
|
||||||
_vp="$(volume_path "$_v")"
|
|
||||||
_vm="$(volume_mode "$_v")"
|
|
||||||
set +e
|
|
||||||
swap_in "$_vp" "$_vm"
|
|
||||||
_rc=$?
|
|
||||||
set -e
|
|
||||||
if [ "$_rc" -ne 0 ]; then
|
|
||||||
log "Сбой замены в томе '$_v' — возвращаю прежние данные во все тома…"
|
|
||||||
swap_back "$_vp" "$_vm" "$_rc"
|
|
||||||
for _u in $_done; do swap_back "$(volume_path "$_u")" "$(volume_mode "$_u")" 2; done
|
|
||||||
die "замена не удалась, прежние данные возвращены на место."
|
|
||||||
fi
|
|
||||||
_done="$_v $_done"
|
|
||||||
done
|
|
||||||
for _v in db $DATA_DIRS; do echo committed > "$(volume_path "$_v")/$PHASE"; done
|
|
||||||
for _v in db $DATA_DIRS; do
|
|
||||||
_vp="$(volume_path "$_v")"
|
|
||||||
rm -rf "${_vp:?}/$OLD"
|
|
||||||
rm -f "$_vp/$PHASE"
|
|
||||||
done
|
|
||||||
trap - EXIT
|
|
||||||
log "Данные восстановлены. Запустите приложение: docker compose start app"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Следы прерванной замены (обрыв питания, kill): файлы фазы или .restore-old в томах.
|
|
||||||
# Вывод — имена томов в одну строку через пробел.
|
|
||||||
interrupted_volumes() {
|
|
||||||
for _v in db $DATA_DIRS; do
|
|
||||||
_vp="$(volume_path "$_v")"
|
|
||||||
if [ -f "$_vp/$PHASE" ] || [ -d "$_vp/$OLD" ]; then printf '%s ' "$_v"; fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
refuse_if_interrupted() {
|
|
||||||
[ -z "$(interrupted_volumes)" ] \
|
|
||||||
|| die "найдены следы прерванного восстановления — сначала выполните: fs-backup recover"
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_restore_args() { # общие флаги restore/import → _pos _repo _yes _skip
|
|
||||||
_pos=""
|
|
||||||
_repo=local
|
|
||||||
_yes=no
|
|
||||||
_skip=no
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;;
|
|
||||||
--yes) _yes=yes ;;
|
|
||||||
--no-pre-restore) _skip=yes ;;
|
|
||||||
-*) die "неизвестный аргумент '$1'" ;;
|
|
||||||
*) _pos="$1" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── restore ──────────────────────────────────────────────────────────────────
|
|
||||||
cmd_restore() {
|
|
||||||
parse_restore_args "$@"
|
|
||||||
[ -n "$_pos" ] || die "укажите снимок: fs-backup restore <id|latest> [--repo local|vps] --yes"
|
|
||||||
check_repo_name "$_repo"
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
take_lock
|
|
||||||
ensure_app_stopped
|
|
||||||
refuse_if_interrupted
|
|
||||||
_id="$(resolve_snapshot "$_repo" "$_pos")"
|
|
||||||
r "$_repo" snapshots "$_id" --compact >&2 || true
|
|
||||||
if [ "$_yes" != yes ]; then
|
|
||||||
log "Этот снимок ЗАМЕНИТ текущие БД, uploads и achievements (текущие попадут в снимок pre-restore)."
|
|
||||||
log "Если всё верно — повторите команду с --yes."
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Шаг 1: объём и число файлов по манифесту снимка.
|
|
||||||
_manifest="$RUNTIME_DIR/manifest.tsv"
|
|
||||||
r "$_repo" ls --json "$_id" | jq -r 'select(.struct_type == "node" and .type == "file") | "\(.size)\t\(.path)"' > "$_manifest" \
|
|
||||||
|| die "не удалось прочитать содержимое снимка."
|
|
||||||
_total="$(awk -F '\t' '{ s += $1 } END { print s + 0 }' "$_manifest")"
|
|
||||||
_n_uploads="$(awk -F '\t' -v p="$SNAP_ROOT/uploads/" 'index($2, p) == 1 { n++ } END { print n + 0 }' "$_manifest")"
|
|
||||||
_n_ach="$(awk -F '\t' -v p="$SNAP_ROOT/achievements/" 'index($2, p) == 1 { n++ } END { print n + 0 }' "$_manifest")"
|
|
||||||
log "Снимок: $(human "$_total"), файлов в uploads: $_n_uploads, в achievements: $_n_ach."
|
|
||||||
check_space "$_total"
|
|
||||||
|
|
||||||
# Шаг 2: разворачивание в .restore-new внутри каждого тома.
|
|
||||||
trap 'clean_staging' EXIT
|
|
||||||
prepare_staging
|
|
||||||
log "Разворачивание снимка в промежуточные директории ($NEW)…"
|
|
||||||
r "$_repo" dump "$_id" "$SNAP_ROOT/$DB_NAME" > "$DB_VOLUME/$NEW/$DB_NAME" \
|
|
||||||
|| die "не удалось извлечь БД из снимка — текущие данные НЕ тронуты."
|
|
||||||
for _d in $DATA_DIRS; do
|
|
||||||
r "$_repo" restore "$_id:$SNAP_ROOT/$_d" --target "$SNAP_ROOT/$_d/$NEW" --verify >&2 \
|
|
||||||
|| die "не удалось развернуть $_d — текущие данные НЕ тронуты."
|
|
||||||
done
|
|
||||||
|
|
||||||
# Шаг 3–6.
|
|
||||||
verify_staging "$_n_uploads" "$_n_ach"
|
|
||||||
apply_staging "$_skip"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── import ───────────────────────────────────────────────────────────────────
|
|
||||||
cmd_import() {
|
|
||||||
parse_restore_args "$@"
|
|
||||||
_file="$_pos"
|
|
||||||
[ -n "$_file" ] || die "укажите архив: fs-backup import /import/fs.tar --yes"
|
|
||||||
[ -f "$_file" ] || die "файл $_file не найден. Скопируйте архив в контейнер: docker compose cp fs.tar backup:/import/fs.tar"
|
|
||||||
if [ "$_skip" = no ]; then require_password; fi
|
|
||||||
take_lock
|
|
||||||
ensure_app_stopped
|
|
||||||
refuse_if_interrupted
|
|
||||||
|
|
||||||
# Сжатие определяем по сигнатуре: gzip (старые fs_*.tar.gz) или обычный tar (export).
|
|
||||||
_z=""
|
|
||||||
if [ "$(head -c 2 "$_file" | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then _z="-z"; fi
|
|
||||||
_list="$RUNTIME_DIR/import.list"
|
|
||||||
tar $_z -tf "$_file" > "$_list.raw" 2>/dev/null || die "архив повреждён или это не tar."
|
|
||||||
sed -e 's#^\./##' -e 's#^/##' "$_list.raw" > "$_list"
|
|
||||||
# Tar, обрезанный ровно по границе файла, читается «успешно», но без хвоста. Целый tar
|
|
||||||
# не меньше суммы (заголовок 512 + данные с выравниванием до 512) по всем записям плюс
|
|
||||||
# два нулевых блока маркера конца. У .tar.gz обрыв и так ловит CRC gzip.
|
|
||||||
if [ -z "$_z" ]; then
|
|
||||||
_min="$(tar -tvf "$_file" | awk '{ t += 512 + int(($3 + 511) / 512) * 512 } END { print t + 1024 }')"
|
|
||||||
[ "$(stat -c %s "$_file")" -ge "$_min" ] || die "архив обрезан (нет конца tar) — скачайте его заново."
|
|
||||||
fi
|
|
||||||
grep -qx "$DB_NAME" "$_list" || die "в архиве нет $DB_NAME — это не бэкап Forbidden Stars."
|
|
||||||
_n_uploads="$(grep '^uploads/.' "$_list" | grep -vc '/$' || true)"
|
|
||||||
_n_ach="$(grep '^achievements/.' "$_list" | grep -vc '/$' || true)"
|
|
||||||
_size="$(stat -c %s "$_file")"
|
|
||||||
if [ -n "$_z" ]; then _size=$((_size * 2)); fi
|
|
||||||
log "Архив: $_file ($(human "$(stat -c %s "$_file")")), файлов в uploads: $_n_uploads, в achievements: $_n_ach."
|
|
||||||
if [ "$_yes" != yes ]; then
|
|
||||||
log "Архив ЗАМЕНИТ текущие БД, uploads и achievements (текущие попадут в снимок pre-restore)."
|
|
||||||
log "Если всё верно — повторите команду с --yes."
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
check_space "$_size"
|
|
||||||
|
|
||||||
# Шаг 2: распаковка в том uploads (самый большой), затем раскладка по .restore-new томов:
|
|
||||||
# uploads — rename в пределах тома, БД и achievements (маленькие) — копированием.
|
|
||||||
trap 'clean_staging' EXIT
|
|
||||||
prepare_staging
|
|
||||||
_u="$SNAP_ROOT/uploads/$UNPACK"
|
|
||||||
mkdir -p "$_u"
|
|
||||||
log "Распаковка архива в промежуточную директорию…"
|
|
||||||
tar $_z -xf "$_file" -C "$_u" || die "не удалось распаковать архив — текущие данные НЕ тронуты."
|
|
||||||
cp "$_u/$DB_NAME" "$DB_VOLUME/$NEW/$DB_NAME"
|
|
||||||
if [ -d "$_u/uploads" ]; then
|
|
||||||
rmdir "$SNAP_ROOT/uploads/$NEW"
|
|
||||||
mv "$_u/uploads" "$SNAP_ROOT/uploads/$NEW"
|
|
||||||
fi
|
|
||||||
if [ -d "$_u/achievements" ]; then cp -a "$_u/achievements/." "$SNAP_ROOT/achievements/$NEW/"; fi
|
|
||||||
rm -rf "$_u"
|
|
||||||
|
|
||||||
verify_staging "$_n_uploads" "$_n_ach"
|
|
||||||
apply_staging "$_skip"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── recover: разбор прерванного восстановления ──────────────────────────────
|
|
||||||
# Если все тома успели замениться (committed) — дочищаем, новые данные остаются.
|
|
||||||
# Иначе возвращаем каждому тому прежние данные по его фазе; затем restore можно повторить.
|
|
||||||
cmd_recover() {
|
|
||||||
take_lock
|
|
||||||
ensure_app_stopped
|
|
||||||
_vols="$(interrupted_volumes)"
|
|
||||||
if [ -z "$_vols" ]; then
|
|
||||||
log "Следов прерванного восстановления нет — делать ничего не нужно."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
_committed=no
|
|
||||||
for _v in $_vols; do
|
|
||||||
if [ "$(cat "$(volume_path "$_v")/$PHASE" 2>/dev/null)" = committed ]; then _committed=yes; fi
|
|
||||||
done
|
|
||||||
if [ "$_committed" = yes ]; then
|
|
||||||
log "Замена успела завершиться во всех томах — дочищаю промежуточные директории…"
|
|
||||||
for _v in $_vols; do
|
|
||||||
_vp="$(volume_path "$_v")"
|
|
||||||
rm -rf "${_vp:?}/$OLD"
|
|
||||||
rm -f "$_vp/$PHASE"
|
|
||||||
done
|
|
||||||
clean_staging
|
|
||||||
log "Готово: восстановленные данные на месте. Запустите приложение: docker compose start app"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
log "Замена не завершилась — возвращаю прежние данные (тома: ${_vols% })…"
|
|
||||||
for _v in $_vols; do
|
|
||||||
_vp="$(volume_path "$_v")"
|
|
||||||
case "$(cat "$_vp/$PHASE" 2>/dev/null)" in
|
|
||||||
old) swap_back "$_vp" "$(volume_mode "$_v")" 1 ;;
|
|
||||||
*) swap_back "$_vp" "$(volume_mode "$_v")" 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ -z "$(interrupted_volumes)" ] || die "вернуть удалось не всё — см. сообщения выше."
|
|
||||||
clean_staging
|
|
||||||
log "Готово: данные — как до восстановления. Запустите приложение (docker compose start app)"
|
|
||||||
log "или повторите восстановление нужного снимка."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── restic (для продвинутых операций) ────────────────────────────────────────
|
|
||||||
cmd_restic() {
|
|
||||||
[ $# -ge 1 ] || die "использование: fs-backup restic <local|vps> <команда restic…>"
|
|
||||||
_repo="$1"
|
|
||||||
shift
|
|
||||||
check_repo_name "$_repo"
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
r "$_repo" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_init() {
|
|
||||||
require_password
|
|
||||||
setup_ssh
|
|
||||||
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
|
||||||
docker compose exec backup fs-backup <команда>
|
|
||||||
|
|
||||||
Повседневное:
|
|
||||||
status состояние: последние бэкапы/проверки, размеры репозиториев
|
|
||||||
list [local|vps] хронология снимков (время, игроков, партий, размер, прирост)
|
|
||||||
run [--tag имя] [--allow-empty]
|
|
||||||
сделать снимок сейчас; с --tag снимок именованный и очисткой
|
|
||||||
не удаляется (например, --tag before-update); --allow-empty —
|
|
||||||
разрешить снимок пустой БД, когда в истории есть данные
|
|
||||||
verify проверить целостность данных в репозиториях
|
|
||||||
|
|
||||||
Восстановление (сначала: docker compose stop app; после: docker compose start app):
|
|
||||||
restore <id|latest> [--repo local|vps] --yes
|
|
||||||
восстановить снимок (через промежуточную директорию,
|
|
||||||
текущие данные сохраняются в снимок pre-restore)
|
|
||||||
import /import/<файл.tar|.tar.gz> --yes
|
|
||||||
восстановить из архива (export или старый fs_*.tar.gz);
|
|
||||||
архив положить в контейнер: docker compose cp fs.tar backup:/import/
|
|
||||||
recover разобрать прерванное восстановление (обрыв питания и т.п.)
|
|
||||||
|
|
||||||
Выгрузка:
|
|
||||||
export <id|latest> [--repo local|vps] > fs.tar
|
|
||||||
снимок в tar без сжатия (нужен exec -T)
|
|
||||||
|
|
||||||
Прочее:
|
|
||||||
init создать репозитории (делается автоматически)
|
|
||||||
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
|
||||||
health проверка для healthcheck
|
|
||||||
|
|
||||||
Подробная инструкция: deploy/backup/README.md
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── main ─────────────────────────────────────────────────────────────────────
|
|
||||||
mkdir -p "$RUNTIME_DIR" "$STATE_DIR" 2>/dev/null || true
|
|
||||||
_cmd="${1:-help}"
|
|
||||||
[ $# -eq 0 ] || shift
|
|
||||||
case "$_cmd" in
|
|
||||||
run) cmd_run "$@" ;;
|
|
||||||
list) cmd_list "$@" ;;
|
|
||||||
status) cmd_status "$@" ;;
|
|
||||||
verify) cmd_verify "$@" ;;
|
|
||||||
restore) cmd_restore "$@" ;;
|
|
||||||
import) cmd_import "$@" ;;
|
|
||||||
recover) cmd_recover "$@" ;;
|
|
||||||
export) cmd_export "$@" ;;
|
|
||||||
info) cmd_info "$@" ;;
|
|
||||||
init) cmd_init "$@" ;;
|
|
||||||
restic) cmd_restic "$@" ;;
|
|
||||||
health) cmd_health "$@" ;;
|
|
||||||
has-snapshots) cmd_has_snapshots "$@" ;;
|
|
||||||
help|-h|--help) usage ;;
|
|
||||||
*) usage >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
+10
-42
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл
|
На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл
|
||||||
ключа туннеля не нужны:
|
ключа туннеля не нужны:
|
||||||
- образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`);
|
- образы (`app` + `tunnel`) тянутся из Gitea-реестра (`pull_policy: always`);
|
||||||
- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64).
|
- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64).
|
||||||
|
|
||||||
`docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет),
|
`docker compose up` поднимает два контейнера: `app` (FastAPI+SPA, портов на хост нет) и
|
||||||
`tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic,
|
`tunnel` (`ssh -R 9000:app:8000` к VPS). Публичная точка — VPS, домен `forbiddenstars.ru`
|
||||||
см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель
|
(Pi за CGNAT — туннель стучится наружу сам).
|
||||||
стучится наружу сам).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,23 +86,6 @@ IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по
|
|||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
```
|
```
|
||||||
`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать.
|
`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать.
|
||||||
Блок `BACKUP_*` можно оставить пустым: бэкапы включаются позже, по
|
|
||||||
[`deploy/backup/README.md`](../backup/README.md).
|
|
||||||
|
|
||||||
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
|
||||||
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
|
||||||
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
|
|
||||||
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
|
|
||||||
>
|
|
||||||
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
|
|
||||||
> его не сменить:
|
|
||||||
> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi;
|
|
||||||
> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env`
|
|
||||||
> только при создании; без этого шага команда ниже увидит старый пароль);
|
|
||||||
> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`.
|
|
||||||
>
|
|
||||||
> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются.
|
|
||||||
> Логин (`ADMIN_USERNAME`) команда не меняет.
|
|
||||||
|
|
||||||
## 4. Запуск
|
## 4. Запуск
|
||||||
```bash
|
```bash
|
||||||
@@ -112,8 +94,7 @@ docker compose up -d # pull_policy: always → тянет обр
|
|||||||
docker compose ps # app healthy → поднимется tunnel
|
docker compose ps # app healthy → поднимется tunnel
|
||||||
docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@...
|
docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@...
|
||||||
```
|
```
|
||||||
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`
|
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`.
|
||||||
(если админа ещё нет).
|
|
||||||
|
|
||||||
## 5. Проверка
|
## 5. Проверка
|
||||||
- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку).
|
- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку).
|
||||||
@@ -122,31 +103,18 @@ docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:a
|
|||||||
|
|
||||||
## Обновление
|
## Обновление
|
||||||
```bash
|
```bash
|
||||||
# Pi: docker compose exec backup fs-backup run --tag before-update # по желанию, если бэкапы включены
|
|
||||||
# ПК: .\scripts\build-push.ps1
|
# ПК: .\scripts\build-push.ps1
|
||||||
# Pi: docker compose up -d # always-pull подтянет свежие образы
|
# Pi: docker compose up -d # always-pull подтянет свежий образ
|
||||||
```
|
```
|
||||||
Если в новой версии менялся `docker-compose.yml` или `.env.example`, сначала скачайте
|
|
||||||
свежий `docker-compose.yml` (шаг 3) и допишите новые переменные в `.env`.
|
|
||||||
|
|
||||||
## Автозапуск после перезагрузки
|
## Автозапуск после перезагрузки
|
||||||
Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
|
Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
|
||||||
контейнеры поднимутся сами (используют локальный образ, без повторного pull).
|
контейнеры поднимутся сами (используют локальный образ, без повторного pull).
|
||||||
|
|
||||||
## Бэкапы
|
## Бэкап (опционально)
|
||||||
Отдельный контейнер `backup` в том же `docker-compose.yml`: каждую ночь делает зашифрованный
|
Бэкап-скриптам нужен сам `docker-compose.yml` (он на Pi) + скрипты. Скопируй рядом
|
||||||
снимок БД, `uploads` и `achievements` на Pi и на VPS. Дополнительных файлов на Pi не нужно,
|
`scripts/backup.sh` и `scripts/restore.sh`, настрой `BACKUP_*` в `.env` и cron. Подробно —
|
||||||
всё настраивается блоком `BACKUP_*` в `.env`. Пока `BACKUP_PASSWORD` пуст, бэкапы выключены.
|
комментарии в `scripts/backup.sh` и `deploy/vps/README.md` §8.
|
||||||
|
|
||||||
Пошаговая настройка, восстановление и действия при гибели Pi —
|
|
||||||
[`deploy/backup/README.md`](../backup/README.md).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backup fs-backup status # состояние
|
|
||||||
docker compose exec backup fs-backup list # хронология снимков
|
|
||||||
```
|
|
||||||
|
|
||||||
> Не выполняйте `docker compose down -v`: флаг `-v` удаляет тома с данными и локальными бэкапами.
|
|
||||||
|
|
||||||
## Если что-то не так
|
## Если что-то не так
|
||||||
- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel`
|
- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel`
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}"
|
|||||||
UPSTREAM="${UPSTREAM:-app:8000}"
|
UPSTREAM="${UPSTREAM:-app:8000}"
|
||||||
|
|
||||||
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
|
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
|
||||||
# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте).
|
# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте).
|
||||||
mkdir -p /root/.ssh
|
mkdir -p /root/.ssh
|
||||||
KEY=/root/.ssh/id_tunnel
|
KEY=/root/.ssh/id_tunnel
|
||||||
if [ -n "${TUNNEL_KEY_B64:-}" ]; then
|
if [ -n "${TUNNEL_KEY_B64:-}" ]; then
|
||||||
|
|||||||
+5
-35
@@ -2,16 +2,11 @@
|
|||||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||||
#
|
#
|
||||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
||||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||||
#
|
#
|
||||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||||
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||||
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
||||||
#
|
|
||||||
# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не
|
|
||||||
# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для
|
|
||||||
# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это
|
|
||||||
# делает клиентский IP достоверным.
|
|
||||||
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
||||||
#
|
#
|
||||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||||
@@ -23,27 +18,8 @@
|
|||||||
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
||||||
# Файл заглушки — deploy/vps/maintenance.html.
|
# Файл заглушки — deploy/vps/maintenance.html.
|
||||||
|
|
||||||
# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
|
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима.
|
||||||
(edge) {
|
(edge) {
|
||||||
# Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff —
|
|
||||||
# запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить
|
|
||||||
# в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не
|
|
||||||
# светить используемый прокси.
|
|
||||||
header {
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
|
||||||
-Server
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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) НЕ кэшируем. Иначе браузер отдаёт старый
|
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||||
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
||||||
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
||||||
@@ -70,14 +46,11 @@ forbiddenstars.ru {
|
|||||||
handle @sse {
|
handle @sse {
|
||||||
reverse_proxy 127.0.0.1:9000 {
|
reverse_proxy 127.0.0.1:9000 {
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9000 {
|
reverse_proxy 127.0.0.1:9000
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
import edge
|
import edge
|
||||||
}
|
}
|
||||||
@@ -88,14 +61,11 @@ forbidden-stars.ru {
|
|||||||
handle @sse {
|
handle @sse {
|
||||||
reverse_proxy 127.0.0.1:9001 {
|
reverse_proxy 127.0.0.1:9001 {
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9001 {
|
reverse_proxy 127.0.0.1:9001
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
import edge
|
import edge
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-27
@@ -1,11 +1,11 @@
|
|||||||
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
|
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
|
||||||
|
|
||||||
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
|
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
|
||||||
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev).
|
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test).
|
||||||
|
|
||||||
```
|
```
|
||||||
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD
|
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
||||||
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV
|
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||||
```
|
```
|
||||||
|
|
||||||
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит
|
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит
|
||||||
@@ -51,9 +51,7 @@ install -d -m 700 -o tunnel -g tunnel /home/tunnel/.ssh
|
|||||||
install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys
|
install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys
|
||||||
```
|
```
|
||||||
Публичные ключи Pi и ПК добавишь в `/home/tunnel/.ssh/authorized_keys` на шагах настройки
|
Публичные ключи Pi и ПК добавишь в `/home/tunnel/.ssh/authorized_keys` на шагах настройки
|
||||||
Pi (`deploy/pi/README.md`) и ПК (`deploy/tunnel/id_tunnel.pub` и/или ключ по умолчанию из
|
Pi (`deploy/pi/README.md`) и ПК (через `ssh-copy-id`).
|
||||||
`~/.ssh` для dev-туннеля). Ключи **дописывай** (`>>`), а не перезаписывай файл (`>`):
|
|
||||||
иначе туннель другого хоста перестанет пускать.
|
|
||||||
|
|
||||||
## 5. Сертификаты
|
## 5. Сертификаты
|
||||||
|
|
||||||
@@ -69,11 +67,11 @@ Caddy читает **PEM** (текст с `-----BEGIN CERTIFICATE-----`). Рас
|
|||||||
|
|
||||||
Удобно собрать прямо на VPS — залей свои файлы и склей:
|
Удобно собрать прямо на VPS — залей свои файлы и склей:
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /etc/caddy/certs/forbidden-stars.ru /etc/caddy/certs/forbiddenstars.ru /root/certs-tmp
|
mkdir -p /etc/caddy/certs/forbidden-stars.ru /root/certs-tmp
|
||||||
# с локальной машины (пример для домена forbidden-stars.ru; имена файлов — свои):
|
# с локальной машины (пример для домена forbidden-stars.ru):
|
||||||
scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/
|
scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/
|
||||||
# на VPS — собрать fullchain (leaf + промежуточный) и положить ключ:
|
# на VPS — собрать fullchain (leaf + промежуточный) и положить ключ:
|
||||||
cat /root/certs-tmp/forbidden-stars.crt /root/certs-tmp/intermediate.crt \
|
cat /root/certs-tmp/www_forbidden_stars_ru_2026_12_31.crt /root/certs-tmp/intermediate_pem_globalsign_ssl_dv_free_1.crt \
|
||||||
> /etc/caddy/certs/forbidden-stars.ru/fullchain.pem
|
> /etc/caddy/certs/forbidden-stars.ru/fullchain.pem
|
||||||
cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem
|
cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem
|
||||||
# то же для forbiddenstars.ru (свои crt/intermediate/key), затем права
|
# то же для forbiddenstars.ru (свои crt/intermediate/key), затем права
|
||||||
@@ -98,16 +96,13 @@ scp deploy/vps/maintenance.html root@186.246.51.17:/etc/caddy/maintenance/mainte
|
|||||||
caddy validate --config /etc/caddy/Caddyfile
|
caddy validate --config /etc/caddy/Caddyfile
|
||||||
systemctl reload caddy
|
systemctl reload caddy
|
||||||
```
|
```
|
||||||
> Заглушка живёт в сниппете `(edge)` Caddyfile (там же security-заголовки и `no-store` для
|
> Заглушка живёт в сниппете `(offline)` Caddyfile: при ответе апстрима 502/503/504
|
||||||
> HTML): при ответе апстрима 502/503/504 Caddy отдаёт `/etc/caddy/maintenance/maintenance.html`
|
> Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` со статусом 503 и `Cache-Control: no-store`.
|
||||||
> со статусом 503 и `Cache-Control: no-store`. Для `/api/events` (SSE) у каждого домена
|
|
||||||
> отдельный `handle` без `encode` и с `flush_interval -1`.
|
|
||||||
> Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен,
|
> Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен,
|
||||||
> файл читается на каждый запрос).
|
> файл читается на каждый запрос).
|
||||||
|
|
||||||
## 7. Проверка
|
## 7. Проверка
|
||||||
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК
|
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`).
|
||||||
(лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`).
|
|
||||||
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
|
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
|
||||||
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
|
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
|
||||||
(HTTP 503), это ожидаемо.
|
(HTTP 503), это ожидаемо.
|
||||||
@@ -115,15 +110,16 @@ systemctl reload caddy
|
|||||||
Логи Caddy: `journalctl -u caddy -f`.
|
Логи Caddy: `journalctl -u caddy -f`.
|
||||||
|
|
||||||
## 8. Приём бэкапов с Pi (оффсайт-копии)
|
## 8. Приём бэкапов с Pi (оффсайт-копии)
|
||||||
Контейнер `backup` на Pi каждую ночь отправляет зашифрованный снимок (restic) на VPS по SFTP.
|
Pi ежедневно шлёт полный архив (`scripts/backup.sh`) на VPS отдельным пользователем `backup`
|
||||||
Для этого на VPS нужен отдельный пользователь `fsbackup` **только для SFTP**: без shell,
|
(не путать с `tunnel`). Каталог — **вне** веб-корней Caddy, права 700.
|
||||||
без туннелей, без пароля. Репозиторий лежит в `/srv/fs-backups/restic`, вне веб-корней
|
```bash
|
||||||
Caddy, так что бэкапы не публичны.
|
useradd -m -s /bin/bash backup
|
||||||
|
install -d -m 700 -o backup -g backup /home/backup/.ssh
|
||||||
> Имя не `backup`: в Debian/Ubuntu системный пользователь `backup` уже существует.
|
install -m 600 -o backup -g backup /dev/null /home/backup/.ssh/authorized_keys
|
||||||
|
install -d -m 700 -o backup -g backup /srv/fs-backups
|
||||||
Пошаговая настройка: создание пользователя, ключ, `sshd_config.d/60-fs-backup.conf`,
|
# Публичный ключ бэкапа с Pi (deploy/backup/id_backup.pub) добавь в authorized_keys:
|
||||||
проверки — [`deploy/backup/README.md`, шаг 3](../backup/README.md#шаг-3-vps-пользователь-только-для-sftp).
|
# echo '<содержимое id_backup.pub>' >> /home/backup/.ssh/authorized_keys
|
||||||
|
```
|
||||||
Проверка приёма после первого бэкапа: `ls -la /srv/fs-backups/restic` (там `config`, `data`,
|
> Ротацию на VPS делает сам скрипт с Pi (`BACKUP_KEEP_REMOTE`, по умолч. 30). Каталог
|
||||||
`index`, `keys`, `snapshots`). Старые снимки удаляет сам контейнер с Pi по политике хранения.
|
> `/srv/fs-backups` не отдаётся Caddy (нет `root`/`file_server` на него) → бэкапы не публичны.
|
||||||
|
> Проверка приёма: после `scripts/backup.sh` на Pi → `ls -1 /srv/fs-backups/` на VPS.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
#
|
#
|
||||||
# Отличия от docker-compose.yml (прод на Pi):
|
# Отличия от docker-compose.yml (прод на Pi):
|
||||||
# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится;
|
# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится;
|
||||||
# • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК.
|
# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК.
|
||||||
# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck).
|
# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck).
|
||||||
#
|
#
|
||||||
# Запуск: docker compose -f docker-compose.temp.yml up -d --build
|
# Запуск: docker compose -f docker-compose.temp.yml up -d --build
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Локальный «клон прода» в контейнере — для проверки прод-сборки на своей машине
|
||||||
|
# (а не на 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
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data-test:
|
||||||
|
uploads-data-test:
|
||||||
|
achievements-data-test:
|
||||||
+2
-46
@@ -6,13 +6,9 @@
|
|||||||
# (scripts/build-push.*) — на Pi он не используется.
|
# (scripts/build-push.*) — на Pi он не используется.
|
||||||
#
|
#
|
||||||
# Запуск/обновление: docker compose up -d (сам тянет свежие образы)
|
# Запуск/обновление: docker compose up -d (сам тянет свежие образы)
|
||||||
# Логи: docker compose logs -f app (или: ... tunnel / backup)
|
# Логи: docker compose logs -f app (или: ... tunnel)
|
||||||
# Остановка: docker compose down (данные в томах сохраняются)
|
# Остановка: docker compose down (данные в томах сохраняются)
|
||||||
# Бэкапы: docker compose exec backup fs-backup status (help — все команды)
|
# Бэкап/restore: scripts/backup.sh / scripts/restore.sh
|
||||||
# инструкция: deploy/backup/README.md
|
|
||||||
#
|
|
||||||
# НИКОГДА не выполняйте на проде `docker compose down -v`: флаг -v удаляет тома — и данные
|
|
||||||
# приложения, и локальный репозиторий бэкапов (останется только копия на VPS).
|
|
||||||
#
|
#
|
||||||
# Контейнер ВСЕГДА production: APP_ENV форсится здесь и игнорирует значение из .env.
|
# Контейнер ВСЕГДА production: APP_ENV форсится здесь и игнорирует значение из .env.
|
||||||
# Порты на хост НЕ публикуются — наружу приложение выставляет только сервис tunnel
|
# Порты на хост НЕ публикуются — наружу приложение выставляет только сервис tunnel
|
||||||
@@ -73,47 +69,7 @@ services:
|
|||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
|
||||||
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
|
||||||
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
|
|
||||||
# восстановление — deploy/backup/README.md.
|
|
||||||
backup:
|
|
||||||
build: ./deploy/backup
|
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
|
||||||
pull_policy: always
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены
|
|
||||||
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}
|
|
||||||
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
|
|
||||||
BACKUP_KEEP_MONTHLY: ${BACKUP_KEEP_MONTHLY:-12}
|
|
||||||
BACKUP_COMPRESSION: ${BACKUP_COMPRESSION:-max}
|
|
||||||
BACKUP_MAX_AGE_HOURS: ${BACKUP_MAX_AGE_HOURS:-30}
|
|
||||||
BACKUP_VPS_HOST: ${BACKUP_VPS_HOST:-}
|
|
||||||
BACKUP_VPS_USER: ${BACKUP_VPS_USER:-fsbackup}
|
|
||||||
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:-}
|
|
||||||
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
|
||||||
volumes:
|
|
||||||
- db-data:/fs-db # живая БД (снимается консистентно)
|
|
||||||
- uploads-data:/fs/uploads
|
|
||||||
- achievements-data:/fs/achievements
|
|
||||||
- backup-data:/backup # локальный репозиторий restic + состояние
|
|
||||||
mem_limit: ${BACKUP_MEM_LIMIT:-384m}
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
uploads-data:
|
uploads-data:
|
||||||
achievements-data:
|
achievements-data:
|
||||||
backup-data:
|
|
||||||
|
|||||||
@@ -1,757 +0,0 @@
|
|||||||
# Новая рейтинговая система
|
|
||||||
|
|
||||||
> **Статус:** утверждено (#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, «Калибровка»).
|
|
||||||
@@ -1,868 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Правила Forbidden Stars в Markdown
|
|
||||||
|
|
||||||
Текст правил, разбитый на логические блоки. Из него строится поиск по правилам (веха v1.6):
|
|
||||||
на блоки навешиваются теги и синонимы (#7), затем их индексирует Meilisearch (#10).
|
|
||||||
|
|
||||||
| Файл | Источник | Страниц | Блоков |
|
|
||||||
|---|---|---|---|
|
|
||||||
| [rules.md](rules.md) | буклет «Правила игры» — `fs_rules_rus_web.pdf` | 16 | 184 |
|
|
||||||
| [reference.md](reference.md) | буклет «Справочник» — `fs_reference_rus_web.pdf` | 20 | 427 |
|
|
||||||
| [cards-faq.md](cards-faq.md) | «Уточнение карт», декабрь 2020 — `Уточнение_карт_Forbidden_Stars_декабрь2020.pdf` | 12 | 93 |
|
|
||||||
|
|
||||||
PDF в репозитории нет: они лежат вложениями в задаче #6. Правила и справочник — сканы без
|
|
||||||
текстового слоя, их текст распознан вручную по страницам. У «Уточнения карт» текстовый слой
|
|
||||||
есть, он взят за основу и сверен со страницами. Что не перенесено (обложки, художественный
|
|
||||||
текст, содержание), сказано в шапке каждого файла.
|
|
||||||
|
|
||||||
## Блок
|
|
||||||
|
|
||||||
Блок — наименьшая единица поиска: абзац под заголовком источника, пункт списка статьи
|
|
||||||
справочника или одна карта в уточнениях. Блок начинается маркером — HTML-комментарием,
|
|
||||||
который при отображении не виден:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
<!-- block: rules.battle.retreat.attacker; page: 15 -->
|
|
||||||
#### Атакующий отступает
|
|
||||||
|
|
||||||
Когда атакующий отступает, он должен переместить…
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Граница блока — маркер.** Блок тянется до следующего маркера. Заголовок внутри блока
|
|
||||||
относится к нему. Если у абзаца нет своего заголовка, блок начинается прямо с текста.
|
|
||||||
- **Контекст блока** — цепочка заголовков над ним: `#` — файл, `##` — раздел или статья
|
|
||||||
глоссария, `###`/`####`/`#####` — подразделы. Первый блок раздела несёт его заголовок.
|
|
||||||
- **Нумерованные шаги** (подготовка к игре, шаги битвы) — отдельные блоки, заголовок шага
|
|
||||||
сохраняет номер источника: `### 2. Раунд битвы`, `#### а. Выберите Боевую Карту`.
|
|
||||||
- **Список** остаётся одним блоком вместе с вводной фразой, если пункты не имеют смысла
|
|
||||||
по отдельности. В справочнике каждый пункт статьи — самостоятельное правило, поэтому
|
|
||||||
каждый пункт — свой блок.
|
|
||||||
|
|
||||||
### Поля маркера
|
|
||||||
|
|
||||||
| Поле | Обязательно | Значение |
|
|
||||||
|---|---|---|
|
|
||||||
| `block` | да | постоянный id блока, уникальный во всей папке |
|
|
||||||
| `page` | да | страница PDF; `4-5`, если блок переходит на следующую страницу |
|
|
||||||
| `kind` | нет | тип не-правила (см. ниже); без `kind` блок — правило |
|
|
||||||
|
|
||||||
### Id
|
|
||||||
|
|
||||||
`<файл>.<раздел>[.<подраздел>…]` — латиница в нижнем регистре, слова через дефис, смысловые
|
|
||||||
английские слаги: `rules.orders.advance.limits`, `ref.retreats.defender.not-attacker-system`,
|
|
||||||
`faq.eldar.combat.psychic-lance`. Префикс: `rules.`, `ref.`, `faq.`. Вложенность id повторяет
|
|
||||||
вложенность заголовков, но не обязана совпадать с ней буквально.
|
|
||||||
|
|
||||||
**Id постоянны.** К ним привязываются теги, синонимы и ссылки «см. также» (#7), а через
|
|
||||||
них — поисковый индекс. Исправлять текст блока можно свободно. Id переименовывать нельзя:
|
|
||||||
новый блок получает новый id, а id удалённого блока больше не используется.
|
|
||||||
|
|
||||||
### `kind`
|
|
||||||
|
|
||||||
| Значение | Что это |
|
|
||||||
|---|---|
|
|
||||||
| `example` | врезка «Пример…» — иллюстрирует правило, но и сама может его уточнять |
|
|
||||||
| `caption` | подпись к схеме или иллюстрации. Если схема без подписи несёт информацию (например, раскладка тайлов), её описание составлено при переносе и выделено курсивом |
|
|
||||||
| `related` | строка «Связанные темы» из справочника — готовые связи между статьями |
|
|
||||||
|
|
||||||
## Текст
|
|
||||||
|
|
||||||
- Дословно, включая опечатки и авторскую пунктуацию источника. Ссылки «см. … на стр. N» —
|
|
||||||
номера страниц буклета.
|
|
||||||
- Переносы склеены, колонки и врезки выведены в порядке чтения.
|
|
||||||
- Выделение источника сохранено: жирный — `**…**`, курсив — `*…*`.
|
|
||||||
- Художественный текст (лор) не переносится.
|
|
||||||
|
|
||||||
## Значки
|
|
||||||
|
|
||||||
Значки внутри текста передаются словом в квадратных скобках. При отображении их можно
|
|
||||||
заменить картинкой, а для поиска они остаются словами.
|
|
||||||
|
|
||||||
| Запись | Значок |
|
|
||||||
|---|---|
|
|
||||||
| `[атака]`, `[защита]`, `[мораль]` | боевой значок на любом компоненте (мораль — боевой дух, аквила) |
|
|
||||||
| `[куб: атака]`, `[куб: защита]`, `[куб: мораль]` | значок на грани боевого куба |
|
|
||||||
| `[куб]` | куб, который нужно бросить |
|
|
||||||
| `[жетон: атака]`, `[жетон: защита]` | боевой жетон (значок в круге) |
|
|
||||||
| `[ресурс: кузница]`, `[ресурс: запасы]`, `[ресурс: подкрепление]`, `[ресурс: процветание]` | значок ценного ресурса на знамени мира |
|
|
||||||
| `[приказ: развертывание]`, `[приказ: планирование]`, `[приказ: доминирование]`, `[приказ: продвижение]` | жетон приказа |
|
|
||||||
|
|
||||||
## Проверка
|
|
||||||
|
|
||||||
Формальная проверка файлов (выполнялась при переносе, в репозиторий не входит): у каждого
|
|
||||||
маркера корректный формат, id уникален и имеет префикс своего файла, страница — в пределах
|
|
||||||
PDF, у каждого блока есть текст, каждая непропущенная страница дала хотя бы один блок, в
|
|
||||||
тексте только значки из таблицы выше, нет висячих переносов.
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
# Уточнение карт
|
|
||||||
|
|
||||||
> **Источник:** «Уточнения эффектов карт» — `Уточнение_карт_Forbidden_Stars_декабрь2020.pdf`, 12 стр.
|
|
||||||
> Уточнение по эффектам карт для перевода 2020 года. Автор: Дмитрий Wergg Комаров.
|
|
||||||
> Верстка: Андрей Yxo Паровой. Декабрь 2020.
|
|
||||||
> «Все возможные разночтения в эффектах карт решены на усмотрение автора. Пользуясь данной
|
|
||||||
> справкой, вы соглашаетесь с его виденьем по данному вопросу.»
|
|
||||||
>
|
|
||||||
> **Не перенесено:** стр. 1 (обложка), изображения карт и иллюстрации; выходные данные
|
|
||||||
> со стр. 12 — в этой шапке. Формат блоков и id — в [README](README.md).
|
|
||||||
|
|
||||||
## Общие положения
|
|
||||||
|
|
||||||
<!-- block: faq.general.effect-resolution; page: 2 -->
|
|
||||||
### Розыгрыш эффекта
|
|
||||||
|
|
||||||
- Если карта задает выбор, то выбор делается владельцем карты, если не сказано иного. Как в отношении своих компонентов, так и в отношении компонентов соперника.
|
|
||||||
- **Жирным** в тексте карт выделены смысловые акценты, которые легко пропустить.
|
|
||||||
- Владелец карты вправе отказаться разыгрывать эффект карты, кроме тех случаев, где эффект карты содержит слово **обязан**, выделенное жирным. От исполнения таких эффектов ни вы, ни ваш соперник не имеют права отказаться. Такие эффекты встречаются только в колоде Орков.
|
|
||||||
- Эффекты карт изложены в специальных полях. В боевых картах обычно 2 поля - зеленое и коричневое. В остальных картах поле одно. В одном поле может быть сложносоставной эффект. Решение о розыгрыше или отказе от розыгрыша должно приниматься для всего эффекта, описанного в поле.
|
|
||||||
- Если вы приняли решение разыграть эффект карты, то должны выполнить все предписания в поле настолько, насколько это возможно и именно в том порядке, в котором они перечислены.
|
|
||||||
- Нельзя отказаться от части эффекта карты, если вы приняли решение разыграть эффект.
|
|
||||||
- Если эффект подразумевает трату кубиков, то разыграть такой эффект можно ровно 1 раз, если только не оговорено, что потратить можно любое количество кубиков (и получить соответствующее количество активаций эффекта).
|
|
||||||
|
|
||||||
<!-- block: faq.general.may-avoid; page: 2 -->
|
|
||||||
### "Может избежать этого эффекта..."
|
|
||||||
|
|
||||||
- Карты с формулировкой **"Может избежать этого эффекта..."** построены по принципу: происходит событие **А**. Противник может избежать наступления события путем уплаты стоимости, указанной после слов **"Может избежать этого эффекта..."**
|
|
||||||
- Противник вправе отказаться уплачивать стоимость отмены. В этом случае событие **А** происходит.
|
|
||||||
- Если противник **не может** уплатить стоимость отмены в виду отсутствия необходимых ресурсов или в виду эффектов карт, то событие **А** происходит. Требуется именно уплата стоимости отмены в полном объеме, а не принципиальный факт согласия на уплату.
|
|
||||||
- Если противник согласился уплатить стоимость отмены, но не смог этого сделать в виду каких-либо эффектов карт или других причин, то событие **А** происходит.
|
|
||||||
|
|
||||||
<!-- block: faq.general.retreat; page: 2 -->
|
|
||||||
### Отступите / отступает
|
|
||||||
|
|
||||||
- Если эффект карты предписывает отступать, то отступление производится по общим правилам с соответствующими ограничениями (см. правила). Не забывайте, что после отступления отряд становится деморализованным.
|
|
||||||
- Отряд, отступивший в никем не контролируемую область, делает ее дружественной владельцу отряда.
|
|
||||||
- Если за бой было несколько отступлений, то они могут производиться в разные области.
|
|
||||||
- Если сражение началось по эффекту **карты** приказа, а не по самому приказу (карта "десантный модуль") или в результате действия боевых карт ("волновой змей"), то атакующий не сможет отступать (см правила отступления).
|
|
||||||
- Если сражение началось по приказу "продвижение", вызванному эффектом карты (карта орков "вперед!"), то отступление атакующих - легально.
|
|
||||||
|
|
||||||
<!-- block: faq.general.move; page: 2 -->
|
|
||||||
### Переместите
|
|
||||||
|
|
||||||
- Перемещать деморализованные отряды нельзя. Хоть по эффекту приказа, хоть по эффекту карты.
|
|
||||||
|
|
||||||
<!-- block: faq.general.take-and-place; page: 3 -->
|
|
||||||
### Забрать и поместить / разместить
|
|
||||||
|
|
||||||
- Забрать и поместить в другую область можно только недеморализованные отряды.
|
|
||||||
- При размещении игнорируется вместимость мира. Выбор, кого оставить производится после боя (если размещение было вызвано эффектом карты в бою) или после выполнения приказа (если размещение было сопряжено с приказом).
|
|
||||||
- При размещении отряда в бою игнорируется лимит в "5 отрядов максимум в одной области" т.к. этот лимит наложен только на перемещение.
|
|
||||||
- При размещении отряда в бою после броска кубиков новые кубики за вновь прибывший отряд уже не бросаются.
|
|
||||||
|
|
||||||
<!-- block: faq.general.replace; page: 3 -->
|
|
||||||
### Замените
|
|
||||||
|
|
||||||
- Т.к. в бою (и только в бою!) фишки подкрепления являются отрядами, то заменять их на пластиковые фигурки - легально.
|
|
||||||
- Процедура "заменить" не равна "купить". При замещении игнорируется командный уровень.
|
|
||||||
- Можно заменить деморализованный отряд на недеморализованный.
|
|
||||||
|
|
||||||
<!-- block: faq.general.purchase; page: 3 -->
|
|
||||||
### Купите
|
|
||||||
|
|
||||||
- Только процедура покупки, инициированная жетоном приказа "развертывание" требует наличия фабрики для приобретения отрядов.
|
|
||||||
- Если эффект карты позволяет вам покупать отряды, то наличие фабрики не требуются. Однако командный уровень все еще учитывается.
|
|
||||||
|
|
||||||
<!-- block: faq.general.gain; page: 3 -->
|
|
||||||
### Получите
|
|
||||||
|
|
||||||
- "Получить" не равно "Купить". Получение не требует соблюдения командного уровня.
|
|
||||||
|
|
||||||
<!-- block: faq.general.command-level; page: 3 -->
|
|
||||||
### Командный уровень
|
|
||||||
|
|
||||||
- Ваш фактический командный уровень равен числу ваших городов.
|
|
||||||
- Жетоны кузницы дают вам временное понижение командного уровня покупаемого отряда, сам командный уровень отрядов не меняется. Фактически командный уровень отрядов не уменьшается и всегда равен написанному на листе фракции.
|
|
||||||
- Если эффект карты ссылается на командный уровень отряда или улучшения, то имеется в виду именно то, что написано фактически на листе фракции или карте.
|
|
||||||
- Если эффект предписывает вам получить (а так же разместить или заменить) что-либо, то командный уровень игнорируется.
|
|
||||||
|
|
||||||
<!-- block: faq.general.orbital-strike; page: 3 -->
|
|
||||||
### Орбитальный удар
|
|
||||||
|
|
||||||
- Помните про лимит в 8 кубиков.
|
|
||||||
- Если способность предписывает потратить кубики во время выполнения орбитального удара, то это нужно сделать **до** назначения урона.
|
|
||||||
- Если эффект подразумевает трату кубиков, то разыграть такой эффект можно ровно 1 раз (воруй, грабь, десантный модуль, огненный дождь, обстрел, расчетливый удар, полное разрушение, страх с небес).
|
|
||||||
- Несмертельный урон не приводит к деморализации (в отличии от боя).
|
|
||||||
- Вы в праве объявить орбитальный удар по миру без отрядов (и даже без бастиона) для активации способности.
|
|
||||||
- Нельзя объявить орбитальный удар по никем не контролируемому или своему миру.
|
|
||||||
- Нельзя объявить орбитальный удар по области без миров.
|
|
||||||
- Нельзя объявить орбитальный удар по миру в соседней системе.
|
|
||||||
|
|
||||||
## Орки
|
|
||||||
|
|
||||||
<!-- block: faq.orks.faction-ability; page: 4 -->
|
|
||||||
### Фракционная способность
|
|
||||||
|
|
||||||
Вы можете купить только наземный отряд. Для покупки отряда **не** требуется наличие фабрики. Командный уровень покупаемого отряда учитывается. Вы можете использовать жетон запасов (шестеренки), чтобы уменьшить стоимость на 2. Вы можете использовать жетон кузницы (молот), чтобы снизить требование по командному уровню покупаемого отряда.
|
|
||||||
|
|
||||||
### События
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.more-boyz; page: 4 -->
|
|
||||||
#### Больше бойзов!
|
|
||||||
|
|
||||||
Вы не можете иметь более 3 жетонов 1 типа. Лишние жетоны подкрепления придется сразу сбросить.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.into-battle; page: 4 -->
|
|
||||||
#### В бой!
|
|
||||||
|
|
||||||
При размещении отрядов вы можете превысить лимит мира (количество черепов). Решение, какие отряды оставить в этом мире, принимается после размещения.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.how-did-we-get-here; page: 4 -->
|
|
||||||
#### Как мы сюда попали?
|
|
||||||
|
|
||||||
забрать и поместить можно только недеморализованный отряд. Разместить таким образом можно только наземный отряд.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.looting; page: 4 -->
|
|
||||||
#### Мародерство
|
|
||||||
|
|
||||||
Вы получите материалы, даже если у выбранного противника нет жетонов ценных ресурсов. У вас не может быть больше 14 материалов.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.tear-it-down; page: 4 -->
|
|
||||||
#### Снести это!
|
|
||||||
|
|
||||||
Вы можете уничтожить любую постройку (не только бастион).
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.finish-them; page: 4 -->
|
|
||||||
#### Добить их!
|
|
||||||
|
|
||||||
Для того чтобы воспользоваться эффектом данной карты отряд противника должен стать деморализованным фактически. Если какой-либо эффект предотвратил деморализацию отрядов противника (напр. карта " не ведать страха" фракции Ультрамаринов), то уничтожить такой отряд при помощи этой карты нельзя.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.warboss; page: 4 -->
|
|
||||||
#### Варбосс
|
|
||||||
|
|
||||||
Скидка действует как при покупке при помощи приказа "развертывание", так и при покупке по эффекту карт или фракционной способности. Вы не можете иметь более 3 жетонов подкрепления.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.events.forward; page: 4 -->
|
|
||||||
#### Вперед!
|
|
||||||
|
|
||||||
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой. При выполнении эффекта этой карты считайте, что вы вскрыли приказ "продвижение" в целевой системе. Т.к. считается, что сражение, начавшееся после розыгрыша данной карты, произошло по приказу "продвижение", то отступление атакующего - легально (см правила отступления).
|
|
||||||
|
|
||||||
### Приказы
|
|
||||||
|
|
||||||
<!-- block: faq.orks.orders.build-faster; page: 5 -->
|
|
||||||
#### Строй быстрее!
|
|
||||||
|
|
||||||
После постройки фабрики можно будет сразу же приобретать отряды в этой системе.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.orders.plunder; page: 5 -->
|
|
||||||
#### Грабь!
|
|
||||||
|
|
||||||
Вы не можете бросить более 8 кубиков для орбитального удара, все лишние кубики пропадут. Если у противника нет материалов, то вы все равно получите 1 материал. Если у вас уже 14 материалов, то противник все равно потеряет 1 материал (если вы потратите кубик, конечно).
|
|
||||||
|
|
||||||
<!-- block: faq.orks.orders.green-tide; page: 5 -->
|
|
||||||
#### Зеленая волна
|
|
||||||
|
|
||||||
Вы должны выбрать тип разыгрываемого приказа при вскрытии жетона. Считайте, что на данном жетоне изображена иконка выбранного вами приказа. Если вы выбрали приказ, отличный от приказа "планирование", то не размещайте этот жетон приказа поверх колоды событий.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.orders.steal; page: 5 -->
|
|
||||||
#### Воруй!
|
|
||||||
|
|
||||||
Если у противника нет жетонов ценных ресурсов, то вы не получите ничего. Если у вас уже 3 жетона выбранного ресурса, то противник все равно потеряет выбранный жетон (вы при этом четвертый жетон не получите).
|
|
||||||
|
|
||||||
<!-- block: faq.orks.orders.ork-ships; page: 5 -->
|
|
||||||
#### Орочьи корабли
|
|
||||||
|
|
||||||
Вы можете составить путь из настоящих кораблей и дружественных миров в любой пропорции и дополнительно **однократно** провести не более 2 наземных отрядов через пустую область как через дружественную. Переместить по приказу можно больше 2 отрядов, главное, чтобы именно через пустую область без кораблей по эффекту данной карты было перемещено не более 2 отрядов. Нельзя отступать через пустую область при помощи данной карты в защите. Нельзя отступать при помощи данной карты в атаке, если вы использовали ее для перемещения в оспариваемую область. Можно отступить при помощи данной карты в атаке (с соблюдением всех правил отступления), если вы не использовали ее для перемещения в оспариваемую область.
|
|
||||||
|
|
||||||
### Боевые карты
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.hard-boyz; page: 5 -->
|
|
||||||
#### Хард бойз
|
|
||||||
|
|
||||||
Оба эффекта карты обязательны к исполнению и от них нельзя отказаться.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.mega-nobz; page: 5 -->
|
|
||||||
#### Мега нобз
|
|
||||||
|
|
||||||
верхний эффект обязателен к исполнению и от него нельзя отказаться.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.weirdboyz; page: 5 -->
|
|
||||||
#### Вирдбойз
|
|
||||||
|
|
||||||
верхний эффект обязателен к исполнению и от него нельзя отказаться. Если будет сыграно две карты **вирдбойз**, то их свойства будут складываться, т.е. вы будете получать удвоенное количество жетонов.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.slugga-boyz; page: 5 -->
|
|
||||||
#### Слагга бойз
|
|
||||||
|
|
||||||
Верхний эффект обязателен к исполнению и от него нельзя отказаться.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.biker-nobz; page: 5 -->
|
|
||||||
#### Байкер нобз
|
|
||||||
|
|
||||||
верхний эффект обязателен к исполнению и от него нельзя отказаться.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.mek-boyz; page: 5 -->
|
|
||||||
#### Мек бойз
|
|
||||||
|
|
||||||
Противник сбрасывает карту с **колоды** (не с руки). Вы получаете именно значки (не жетоны). Это может быть важно для эффектов карт, заставляющих сбрасывать боевые жетоны. Если карта была сброшена таким образом в 3 раунде боя, то аквилы (орлы) со сброшенной карты не участвуют в подсчете общего количества морали, т.к. он производится **после** окончания 3 раунда.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.shoota-boyz; page: 5 -->
|
|
||||||
#### Шута бойз
|
|
||||||
|
|
||||||
Оба эффекта карты обязательны к исполнению и от них нельзя отказаться.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.ripping-gargant; page: 5 -->
|
|
||||||
#### Разрывающий гаргант
|
|
||||||
|
|
||||||
если сброшенная карта давала свойство, действующее в течение этого раунда или всего боя, то оно (свойство) немедленно прекращается.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.party-wagon; page: 5 -->
|
|
||||||
#### Пати вагон
|
|
||||||
|
|
||||||
Т.к. жетон размещается бесплатно, то он берется из резерва (а не из вашего запаса). Ограничение на 3 жетона одного типа при этом соответственно игнорируется. Также игнорируется правило, что у вы не можете разместить больше жетонов подкрепления, чем пластиковых фигурок в бою на вашей стороне.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.gretchin; page: 5 -->
|
|
||||||
#### Гретчины
|
|
||||||
|
|
||||||
Несмотря на слово "должен" вы можете не разыгрывать верхний эффект карты. Но тогда вы не получите боевые жетоны.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.crushing-gargant; page: 5 -->
|
|
||||||
#### Сокрушающий гаргант
|
|
||||||
|
|
||||||
если у противника нет необходимого количества кубиков или он не хочет их тратить, то выбранный отряд уничтожается.
|
|
||||||
|
|
||||||
<!-- block: faq.orks.combat.sea-of-green; page: 5 -->
|
|
||||||
#### Море зеленых
|
|
||||||
|
|
||||||
1. Т.к. жетон размещается бесплатно, то он берется из резерва (а не из вашего запаса). Ограничение на 3 жетона одного типа при этом соответственно игнорируется. Также игнорируется правило, что у вы не можете разместить больше жетонов подкрепления, чем фигурок в бою на вашей стороне.
|
|
||||||
2. Если у противника нет кубиков с аквилами (орлами) или он не хочет их тратить, то он должен будет деморализовать 1 свой отряд (если сможет).
|
|
||||||
|
|
||||||
## Космодесант Хаоса
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.faction-ability; page: 6 -->
|
|
||||||
### Фракционная способность
|
|
||||||
|
|
||||||
Т.к. это размещение, а не перемещение, то целевая планета может не иметь легального пути до места изначального размещения культиста и даже может находиться за варп-штормом. Забрать и разместить таким образом можно только недеморализованный отряд.
|
|
||||||
|
|
||||||
### События
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.events.warp-touched; page: 6 -->
|
|
||||||
#### Задетый варпом
|
|
||||||
|
|
||||||
Т.к. вы получаете, а не покупаете улучшение, то ваш фактический командный уровень (число городов) не имеет значения.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.events.nurgle-rot; page: 6 -->
|
|
||||||
#### Отрава Нургла
|
|
||||||
|
|
||||||
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.events.tzeentch-incarnation; page: 6 -->
|
|
||||||
#### Инкарнация Тзинча
|
|
||||||
|
|
||||||
Если эта карта была вытянута в числе нескольких карт в 3 фазе (если на колоде событий лежало более 1 жетона) и была разыграна, то сначала тянуться еще 3 карты и играется одна из них, а только потом все карты замешиваются в колоду событий.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.events.through-the-warp; page: 6 -->
|
|
||||||
#### Сквозь варп
|
|
||||||
|
|
||||||
Вы получите дополнительный кубик с аквилой (орлом) даже если не пересекали варп-шторм перед началом сражения. Помните про лимит в 8 кубиков. Т.к. вы получите кубик с аквилой еще до броска основных кубиков, то фактически вы не сможете бросить больше 7 кубиков (восьмой кубик будет аквилой). Вы можете отступить через варп-шторм после атаки. Вы не можете использовать эту карту для отступления в защите.
|
|
||||||
|
|
||||||
### Приказы
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.orders.from-the-warp; page: 6 -->
|
|
||||||
#### Из варпа
|
|
||||||
|
|
||||||
Наземные отряды **не** могут пересекать варп-штормы по эффекту данной карты. Вы можете отступить через варп-шторм после атаки. Вы не можете использовать эту карту для отступления в защите.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.orders.dark-gods-favor; page: 6 -->
|
|
||||||
#### Расположение темных богов
|
|
||||||
|
|
||||||
Дополнительные жетоны нужно взять из числа неиспользуемых в этом раунде (не с поля). В комбинации с картой **инкарнация Тзинча** можно просмотреть 6 карт из колоды событий.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.orders.dread-ritual; page: 6 -->
|
|
||||||
#### Ужасный ритуал
|
|
||||||
|
|
||||||
Для покупки по эффекту данной карты не требуется завод. Вы можете купить корабль по эффекту данной карты, но разместить его нужно обязательно в дружественной области активной системы. Т.к. вы **покупаете** отряд, то ваш командный уровень все равно учитывается. Вы можете использовать жетон кузницы (молоток), если вам не хватает вашего текущего командного уровня для приобретения отряда. Вы не можете покупать титанов по эффекту данной карты, т.к. наложен запрет на покупку отрядов выше фактического второго уровня.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.orders.terror-from-the-skies; page: 6 -->
|
|
||||||
#### Страх с небес
|
|
||||||
|
|
||||||
Не забывайте, что во время орбитального удара несмертельный урон не приводит к деморализации. Помните про лимит в 8 кубиков.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.orders.total-destruction; page: 6 -->
|
|
||||||
#### Полное разрушение
|
|
||||||
|
|
||||||
Противник должен выбрать для уничтожения любую пластиковую фигурку (отряд или строение) **из имеющихся** в мире, подвергшемся орбитальному удару. Противник вправе выбрать небоевое строение (город/фабрика). Потратить кубики и разыграть способность нужно **до** назначения урона. Несмертельный урон не приводит к деморализации отряда. Вы в праве объявить орбитальный удар по враждебному миру без отрядов и/или бастиона для активации этой способности.
|
|
||||||
|
|
||||||
### Боевые карты
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.wrath-of-khorne; page: 7 -->
|
|
||||||
#### Ярость Кхорна
|
|
||||||
|
|
||||||
Если у противника нет кубика защиты (щит) или он не хочет его тратить, то он должен выбрать и деморализовать свой отряд. Если у противника нет недеморализованных отрядов или они не могут стать деморализованными (карта "не ведать страха"), то не произойдет ничего.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.daemonic-resilience; page: 7 -->
|
|
||||||
#### Демоническая стойкость
|
|
||||||
|
|
||||||
Если у противника нет отрядов для уничтожения или он не хочет этого делать, то вы получите жетоны защиты.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.mark-of-tzeentch-top; page: 7 -->
|
|
||||||
#### Отметка Тзинча (верх)
|
|
||||||
|
|
||||||
можно заменить деморализованный отряд культистов на нормальный отряд космодесанта хаоса. Можно заменить жетон подкрепления на фигурку космодесанта хаоса, т.к. в бою жетон подкрепления является отрядом (культистов).
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.mark-of-tzeentch-bottom; page: 7 -->
|
|
||||||
#### Отметка Тзинча (низ)
|
|
||||||
|
|
||||||
можно выбрать разные грани кубиков, если потрачены 2 аквилы.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.mark-of-slaanesh-top; page: 7 -->
|
|
||||||
#### Отметка Слаанеш (верх)
|
|
||||||
|
|
||||||
если у противника нет недеморализованных отрядов или они не могут стать деморализованными, то не произойдет ничего.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.mark-of-khorne-bottom; page: 7 -->
|
|
||||||
#### Отметка Кхорна (низ)
|
|
||||||
|
|
||||||
если у противника нет деморализованных отрядов, то не произойдет ничего. Если у противника есть деморализованные отряды, но нет кубика защиты (щит) или он не хочет его тратить, то он должен выбрать и уничтожить свой деморализованный отряд.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.death-and-despair-top; page: 7 -->
|
|
||||||
#### Смерть и отчаяние (верх)
|
|
||||||
|
|
||||||
нельзя выбрать 1 болтер и 1 аквилу. Только либо 2 болтера, либо 2 аквилы. Выбор, какой отряд уничтожать, делается игроком за Хаос.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.lures-of-chaos-top; page: 7 -->
|
|
||||||
#### Соблазны хаоса (верх)
|
|
||||||
|
|
||||||
противник вправе выбрать деморализовать 1 свой отряд даже если у него нет недеморализованных отрядов или они не могут стать деморализованными (карта "не ведать страха"). Это приведет к тому, что хаос не получит культиста (т.к. выбран другой вариант, пусть и невыполнимый), а противник хаоса не получит кубик (т.к. он не "уплатил" цену этого кубика - фактически не деморализовал отряд).
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.mark-of-nurgle-bottom; page: 7 -->
|
|
||||||
#### Отметка Нургла (низ)
|
|
||||||
|
|
||||||
если у противника нет деморализованных отрядов или он не хочет их уничтожать, то вы получите жетоны защиты.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.unholy-intent-bottom; page: 7 -->
|
|
||||||
#### Нечистые намерения (низ)
|
|
||||||
|
|
||||||
Если у противника нет недеморализованных отрядов, или они не могут стать деморализованными (карта "не ведать страха"), или он не хочет деморализовать свой отряд, то хаос получит жетоны атаки. Т.е. избежать получения жетонов атаки можно только фактически деморализовав свой отряд.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.dark-faith; page: 7 -->
|
|
||||||
#### Темная вера
|
|
||||||
|
|
||||||
размещенный таким образом культист может отрезать легальный путь к отступлению противника.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.chaos-united-top; page: 7 -->
|
|
||||||
#### Хаос объединенный (верх)
|
|
||||||
|
|
||||||
Противник может выбрать деморализацию своих отрядов даже если у него нет недеморализованных отрядов или они не могут стать деморализованными ( карта "не ведать страха"). Такой выбор не приведет к тому, что Хаос получит кубик. Противник обязан сделать выбор не зная, кубик с каким значением возьмет игрок за Хаос в случае отказа.
|
|
||||||
|
|
||||||
<!-- block: faq.chaos.combat.chaos-united-bottom; page: 7 -->
|
|
||||||
#### Хаос объединенный (низ)
|
|
||||||
|
|
||||||
Можно превысить вместимость мира и даже превысить планку в 5 отрядов в бою т.к. это не перемещение, а размещение.
|
|
||||||
|
|
||||||
## Ультрамарины
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.faction-ability; page: 8 -->
|
|
||||||
### Фракционная способность
|
|
||||||
|
|
||||||
Можно заменить деморализованный отряд на недеморализованный более высокого уровня. Командный уровень при замене игнорируется. Нельзя использовать жетон запасов (шестеренки) для активации этого свойства, т.к. это не покупка.
|
|
||||||
|
|
||||||
### События
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.events.exterminatus; page: 8 -->
|
|
||||||
#### Экстерминатус
|
|
||||||
|
|
||||||
Вы должны заявить эффект данной карты до броска кубиков. Нельзя сначала бросить кубики и посмотреть результат, а затем сыграть эту карту.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.events.steadfast; page: 8 -->
|
|
||||||
#### Непоколебимость
|
|
||||||
|
|
||||||
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.events.pre-battle-ceremony; page: 8 -->
|
|
||||||
#### Церемония перед боем
|
|
||||||
|
|
||||||
Т.к. это покупка, то командный уровень учитывается. Купить можно только улучшение приказа (не боевое улучшение). Скидка дается именно за миры с бастионами, а не сами бастионы.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.events.emperors-protection; page: 8 -->
|
|
||||||
#### Защита Императора
|
|
||||||
|
|
||||||
Т.к. это не строительство, а размещение, то второй вариант розыгрыша карты не может быть оплачен жетоном запасов (шестеренки). За 2 материала вы не покупаете бастион, а активируете способность карты, которая даст вам бастион.
|
|
||||||
|
|
||||||
### Приказы
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.orders.crusade; page: 8 -->
|
|
||||||
#### Крестовый поход
|
|
||||||
|
|
||||||
Вы не обязаны использовать полученный жетон в этой битве. Помните про лимит в 3 жетона одного типа. Вы получите жетон подкрепления, если вы нападаете из одного мира в другой в пределах одной системы и переместили все наземные войска в оспариваемую область.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.orders.recruiting-worlds; page: 8 -->
|
|
||||||
#### Миры призыва
|
|
||||||
|
|
||||||
Чтобы не ошибиться с подсчетом суммарного лимита следует сначала сложить все лимиты миров с фабриками и бастионами в системе. Если в 1 мире находятся и фабрика и бастион или 2 бастиона, то лимит этого мира следует считать дважды (как будто это 2 фабрики, см. эффект карты **направлять верующих**). После этого из получившейся суммы следует вычесть количество бастионов в системе.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.orders.guide-the-faithful; page: 8 -->
|
|
||||||
#### Направлять верующих
|
|
||||||
|
|
||||||
Можно создавать миры с одинаковыми постройками. Если будет создан мир с 2 фабриками, то лимит развертывания будет равен удвоенному количеству черепов этого мира.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.orders.drop-pod; page: 8 -->
|
|
||||||
#### Десантный модуль
|
|
||||||
|
|
||||||
Вы вправе объявить орбитальный удар по миру противника без отрядов и/или бастиона для активации свойства. Сочетание этой карты и карты **крестовый поход** даст вам жетон подкрепления после высадки космодесантника, если остальные условия получения жетона будут соблюдены. Если космодесантник в бою, инициированном эффектом данной карты, должен будет отступить, то его придется уничтожить (см. правила отступления).
|
|
||||||
|
|
||||||
### Боевые карты
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.combat.know-no-fear; page: 9 -->
|
|
||||||
#### Не ведать страха
|
|
||||||
|
|
||||||
Вы должны выбрать будете ли вы использовать верхнее свойство карты в момент ее вскрытия. Если эффект карты активирован, то вы не сможете уплатить цену эффектов с ключевыми словами "деморализуйте свой отряд". Вы все еще вправе выбирать варианты, подразумевающие деморализацию ваших отрядов, если такой выбор задается. В этом случае вы не сможете деморализовать свой отряд, но это и не приведет к автоматическому выбору второго варианта. Подробнее сочетание этой карты с другими смотрите в соответствующих уточнениях (Хаос объединенный, Соблазны хаоса, Ярость Кхорна, Нечистые намерения, Море зеленых)
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.combat.veteran-scouts; page: 9 -->
|
|
||||||
#### Скауты ветераны
|
|
||||||
|
|
||||||
Т.к. проверка окончания боя будет только в конце шага нанесения боевых повреждений, то противнику придется распределять урон в свои отряды даже если все ваши отряды выйдут из боя. Достаточно 1 недеморализованного скаута, чтобы отступить несколькими отрядами (за аналогичное количество кубиков с аквилами). После отступления отряд становится деморализованным. Отступить можно и деморализованным отрядом. Отряд, отступивший в никем не контролируемую область, делает ее дружественной. Отступление по данной карте должно производиться в 1 область. Если будет разыграна вторая карта **скауты ветераны**, то отступление можно будет производить в другую область.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.combat.ambush; page: 9 -->
|
|
||||||
#### Засада
|
|
||||||
|
|
||||||
Трата кубиков с аквилами не предотвращает уничтожение отрядов другим способом (например, от смертельного урона или эффектов карт с ключевым словом "уничтожьте"). Если **засада** разыграна в защите, а игрок за Эльдар ранее в этом раунде разыграл **наставления видящих**, то отряд Эльдар не уничтожается, т.к. эффект засады действует от момента раскрытия карты.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.combat.drop-assault; page: 9 -->
|
|
||||||
#### Десант
|
|
||||||
|
|
||||||
Можно забрать и поместить только недеморализованный отряд
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.combat.power-fist; page: 9 -->
|
|
||||||
#### Бронекулак
|
|
||||||
|
|
||||||
Противник тоже наносит урон. Урон между шагами не складывается. Поэтому если с 1 стороны остался только 1 отряд с выносливостью 3 и против него нужно дважды разыграть урон 2, то после первого шага отряд станет деморализованным, а второй шаг не сделает ничего.
|
|
||||||
|
|
||||||
<!-- block: faq.ultramarines.combat.recon; page: 9 -->
|
|
||||||
#### Разведка
|
|
||||||
|
|
||||||
Т.к. проверка окончания боя будет только в конце шага нанесения боевых повреждений, то противнику придется распределять урон в свои отряды даже если все ваши отряды выйдут из боя. По эффекту данной карты отступить могут любые отряды, а не только скаут/ударный крейсер. После отступления отряд становится деморализованным. Отступить можно и деморализованным отрядом. Отряд, отступивший в никем не контролируемую область, делает ее дружественной. Отступление по данной карте должно производиться в 1 область. Если будет разыграна вторая карта **Разведка**, то отступление можно будет производить в другую область.
|
|
||||||
|
|
||||||
## Эльдар
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.faction-ability; page: 10 -->
|
|
||||||
### Фракционная способность
|
|
||||||
|
|
||||||
Можно превысить лимит мира при размещении отряда. Выбор, кого оставить, производится после размещения. Разместить таким образом можно только наземные отряды. Вам не требуется легальный путь, т.к. это не перемещение, а именно размещение. Забрать и поместить в другую область деморализованный отряд нельзя.
|
|
||||||
|
|
||||||
### События
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.events.legacy-of-vaul; page: 10 -->
|
|
||||||
#### Наследие Ваула
|
|
||||||
|
|
||||||
У вас не может быть более 3 жетонов 1 типа. Лишние придется сбросить.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.events.exodite-colony; page: 10 -->
|
|
||||||
#### Колония экзодитов
|
|
||||||
|
|
||||||
Данное действие не является строительством, это бесплатное размещение города, где цена в 1 материал – это цена активации способности. Нельзя потратить жетон запасов (шестеренки) на активацию этого свойства, т.к. это не строительство.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.events.terror-raid; page: 10 -->
|
|
||||||
#### Ужасный налет
|
|
||||||
|
|
||||||
Помните про лимит в 8 кубиков. Если вы должны получить девятый вы вместо этого не получаете кубик.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.events.path-of-the-warrior; page: 10 -->
|
|
||||||
#### Путь воина
|
|
||||||
|
|
||||||
Купить можно только боевое улучшение колоды (не улучшение приказа). Улучшение приказа таким образом купить нельзя. Скидка считается именно по количеству миров (планет) с городами, а не количеству городов. Командный уровень при покупке учитывается.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.events.warp-gate; page: 10 -->
|
|
||||||
#### Врата варпа
|
|
||||||
|
|
||||||
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой. Забрать и поместить в другую область деморализованный отряд нельзя.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.events.foresight; page: 10 -->
|
|
||||||
#### Предвидение
|
|
||||||
|
|
||||||
Забрать и поместить в другую область деморализованные отряды нельзя. Проверка окончания боя будет только в конце шага нанесения боевых повреждений, значит противнику придется распределять урон в свои отряды (не смотря на то, что все отряды Эльдар выйдут из боя). Игроку за Эльдар урон распределять не нужно, если все его отряды выйдут из боя. Если у игрока за Эльдар в бою останется бастион и/или деморализованные отряды, то урон от противника достанется им, а бой продолжится.
|
|
||||||
|
|
||||||
### Приказы
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.orders.bombardment; page: 10 -->
|
|
||||||
#### Обстрел
|
|
||||||
|
|
||||||
Деморализованные отряды перемещать нельзя.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.orders.calculated-strike; page: 10 -->
|
|
||||||
#### Расчетливый удар
|
|
||||||
|
|
||||||
Помните про лимит в 8 кубиков. Если вы должны получить девятый, вы вместо этого не получаете кубик.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.orders.corsair-raid; page: 10 -->
|
|
||||||
#### Рейд корсаров
|
|
||||||
|
|
||||||
Нельзя выполнить 2 орбитальных удара по эффекту данной карты.
|
|
||||||
|
|
||||||
### Боевые карты
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.hit-and-run; page: 11 -->
|
|
||||||
#### Бей и беги
|
|
||||||
|
|
||||||
Деморализованные отряды перемещать нельзя. Можно переместиться в соседнюю систему, главное, чтобы область назначения была соседней (примыкала ортогонально). Таким перемещением можно отрезать легальный путь для отступления противнику.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.howling-banshees; page: 11 -->
|
|
||||||
#### Воющие баньши
|
|
||||||
|
|
||||||
Если у противника нет отрядов для деморализации или они не могут стать деморализованными по эффекту карт, то не произойдет ничего.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.ranger-cover; page: 11 -->
|
|
||||||
#### Прикрытие рейнджеров
|
|
||||||
|
|
||||||
После отступления отряд становится деморализованным. Отступить можно и деморализованным отрядом. Отряд, отступивший в никем не контролируемую область, делает ее дружественной. Отступление по данной карте должно производиться в 1 область. Если будет разыграна вторая карта **прикрытие рейнджеров**, то отступление можно будет производить в другую область.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.autarch-guidance; page: 11 -->
|
|
||||||
#### Руководство Аутарха
|
|
||||||
|
|
||||||
Помните про лимит в 8 кубиков. Если вы не можете разыграть свойство на получение кубика или восстановление подразделения, то это не запрещает вам разыграть свойство на розыгрыш дополнительной карты.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.wraithguard-advance; page: 11 -->
|
|
||||||
#### Наступление призрачных стражей
|
|
||||||
|
|
||||||
Если у противника нет кубиков с аквилами (орлами), то он должен деморализовать свой отряд (если есть и если это возможно). Если у противника нет недеморализованных отрядов или его отряды не могут стать деморализованными, то не произойдет ничего (противник не обязан тратить кубик с аквилой).
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.seer-counsel; page: 11 -->
|
|
||||||
#### Наставления видящих
|
|
||||||
|
|
||||||
Противник тоже не получит повреждений. Карта не запрещает деморализовать отряды и напрямую их уничтожать по эффектам других карт. Блокируются только боевые повреждения.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.wave-serpent-top; page: 11 -->
|
|
||||||
#### Волновой змей (верхнее свойство)
|
|
||||||
|
|
||||||
Если у противника нет кубиков с аквилами (орлами), то Эльдар получат жетоны защиты.
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.wave-serpent-bottom; page: 11 -->
|
|
||||||
#### Волновой змей (нижнее свойство)
|
|
||||||
|
|
||||||
Деморализованные отряды перемещать нельзя. Можно переместиться в соседнюю систему, главное, чтобы область назначения была соседней (примыкала ортогонально). Такое перемещение может отрезать легальный путь к отступлению сопернику. Отряды Эльдар не смогут отступать из боя, вызванного этим эффектом (см правила отступления).
|
|
||||||
|
|
||||||
<!-- block: faq.eldar.combat.psychic-lance; page: 11 -->
|
|
||||||
#### Психокопье
|
|
||||||
|
|
||||||
Формулировка карты переписана (по сравнению с английской версией). Карта задает выбор: ИЛИ владелец психокопья получает 4 жетона атаки (**А**), ИЛИ противник **соглашается** на то, что владелец психокопья выберет открытую боевую карту соперника и сбросит ее (прикажет ее сбросить) (**Б**). Если у противника нет открытых боевых карт, то это никак не влияет на саму принципиальную возможность **согласиться** на эту процедуру. Противник вправе выбрать вариант **Б**, даже если у него нет отрытых боевых карт (например, идет 1 раунд боя и Эльдар атакуют). В таком случае игрок за Эльдар не получит боевые жетоны, а противник Эльдар не потеряет ничего, т.к. ему нечего терять.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,835 +0,0 @@
|
|||||||
# Правила игры
|
|
||||||
|
|
||||||
> **Источник:** буклет «Правила игры» — `fs_rules_rus_web.pdf`, 16 стр. (скан без текстового
|
|
||||||
> слоя, распознан вручную). Перевод: Копылов Олег.
|
|
||||||
>
|
|
||||||
> **Не перенесено:** стр. 1 (обложка); художественный текст — «Нет мира среди звезд» и описания
|
|
||||||
> фракций (стр. 2), вводный абзац врезки «Что такое варп-штормы?» (стр. 7); иллюстрации. От схем оставлены подписи; описание схемы, составленное
|
|
||||||
> при переносе, выделено курсивом. Формат блоков, id и обозначения иконок — в [README](README.md).
|
|
||||||
|
|
||||||
<!-- block: rules.overview; page: 2 -->
|
|
||||||
## Обзор игры
|
|
||||||
|
|
||||||
*Forbidden Stars* — игра о галактической войне, действие которой происходит во вселенной *Warhammer 40,000.* От двух до четырех игроков командуют Космодесантниками, Орками, Эльдарами или Космодесантниками Хаоса (в базовой игре). Каждая фракция стремится вернуть потерянные реликвии и места силы, которые являются ключевыми для их выживания. Для этого они должны собирать ресурсы, улучшать свои силы и завоевывать миры давно потерянного Скопления Геракон.
|
|
||||||
|
|
||||||
<!-- block: rules.overview.using-this-booklet; page: 2 -->
|
|
||||||
### Использование этого буклета
|
|
||||||
|
|
||||||
Этот буклет «Правила Игры» предназначен для обучения новых игроков игре в Forbidden Stars. Чтобы научить игре быстро и просто, в этом буклете опущены многие исключения из правил и сложные игровые взаимодействия из-за большого количества типов отрядов и боевых карт. Игроки должны использовать прилагающийся Справочник, чтобы разрешить эти ситуации.
|
|
||||||
|
|
||||||
<!-- block: rules.factions; page: 2 -->
|
|
||||||
## Фракции
|
|
||||||
|
|
||||||
В *Forbidden Stars* представлены четыре основные фракции вселенной Warhammer 40,000. Каждая фракция имеет свои уникальные компоненты, которые можно отличить по цвету или по символу фракции. Это позволяет определить, какие компоненты принадлежат каким игрокам.
|
|
||||||
|
|
||||||
- **Орден Космодесантников Ультрамарины**
|
|
||||||
- **Легион-Предатель Космодесантников Хаоса Пожиратели Миров**
|
|
||||||
- **Мир-Корабль Эльдар Йанден**
|
|
||||||
- **Орки Клана Злосолнца**
|
|
||||||
|
|
||||||
<!-- block: rules.components; page: 3 -->
|
|
||||||
## Список компонентов
|
|
||||||
|
|
||||||
- 1 Справочник
|
|
||||||
- 112 Боевых Карт (28 на фракцию)
|
|
||||||
- 32 Карт Событий (8 на фракцию)
|
|
||||||
- 20 Карт Улучшения Приказов (5 на фракцию)
|
|
||||||
- 24 Маркера Целей (6 на фракцию)
|
|
||||||
- 36 Маркеров Контроля Построек (9 на фракцию)
|
|
||||||
- 32 Жетона Приказов (8 на фракцию)
|
|
||||||
- 4 Карты Справок
|
|
||||||
- 1 Счетчик Раунда, 1 Маркер Раунда
|
|
||||||
- 1 Жетон Первого Игрока
|
|
||||||
- 36 Жетонов Ценных Ресурсов (по 12 каждого типа)
|
|
||||||
- 16 Кубов
|
|
||||||
- 12 Боевых Жетонов ([жетон: атака] спереди, [жетон: защита] сзади)
|
|
||||||
- 4 Счетчиков Материалов
|
|
||||||
- 4 Жетона Варп-Штормов
|
|
||||||
- 12 Двусторонних Тайлов Систем
|
|
||||||
- 4 Листа Фракций
|
|
||||||
- 35 Пластиковых Построек (10 Фабрик, 15 Городов,и 10 Бастионов)
|
|
||||||
- 105 Пластиковых Отрядов (27 для Космодесанта, 24 для Эльдаров, 27 для Орков, 27 для Хаоса), и 27 пластиковых подставок (Пластиковые Отряды от КТ визуально отличаются)
|
|
||||||
|
|
||||||
<!-- block: rules.components.material-dials-assembly; page: 3 -->
|
|
||||||
### Сборка счетчиков материалов
|
|
||||||
|
|
||||||
Перед началом игры в *Forbidden Stars* в первый раз, осторожно соберите четыре счетчика материалов, как указано на рисунке ниже. (В редакции от КТ счетчики материалов уже собраны)
|
|
||||||
|
|
||||||
<!-- block: rules.components.ships-assembly; page: 3 -->
|
|
||||||
### Сборка кораблей
|
|
||||||
|
|
||||||
Перед началом игры в *Forbidden Stars*, осторожно вставьте пластиковые подставки в каждую фигурку корабля, как показано на рисунке справа.
|
|
||||||
|
|
||||||
<!-- block: rules.setup; page: 4 -->
|
|
||||||
## Подготовка к игре
|
|
||||||
|
|
||||||
Перед началом игры в *Forbidden Stars*, игроки должны выполнить следующие шаги подготовки к игре:
|
|
||||||
|
|
||||||
<!-- block: rules.setup.choose-factions; page: 4 -->
|
|
||||||
### 1. Выберите Фракции
|
|
||||||
|
|
||||||
Каждый игрок выбирает одну фракцию и берет соответствующие листы фракции, жетоны, карты событий, карты улучшений приказов, боевые карты и отряды этой фракции.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.choose-factions.first-game; page: 4 -->
|
|
||||||
**Если** это ваша первая игра, и игроков меньше четырех, то **исключите** фракции Орков и Эльдар из игры для двух игроков, и фракцию Эльдар из игры для трех игроков.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.starting-components; page: 4 -->
|
|
||||||
### 2. Получите Начальные Компоненты
|
|
||||||
|
|
||||||
Каждый игрок берет все компоненты, перечисленные в графе «*Стартовые Силы*» на обратной стороне листа его фракции. Затем он переворачивает лист своей фракции лицевой стороной вверх и кладет эти компоненты поверх него.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.starting-components.materials; page: 4 -->
|
|
||||||
Наконец, он берет счетчик материалов и устанавливает его на цифру «6». Это значение также указано в поле «*Стартовые Силы*» на обратной стороне листа его фракции.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.first-player; page: 4 -->
|
|
||||||
### 3. Определите Первого Игрока
|
|
||||||
|
|
||||||
Перемешайте по одному маркеру контроля постройки каждого игрока в крышке коробки и вытяните один случайным образом. Этому игроку дается жетон первого игрока.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.game-board; page: 4 -->
|
|
||||||
### 4. Соберите Игровое Поле
|
|
||||||
|
|
||||||
Для первой игры разместите необходимые тайлы систем в центре игровой зоны, как показано на диаграмме «*Расстановка к Первой Игре*» ниже. При игре на менее чем четырех игроков часть игрового поля исключается.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.game-board.place-components; page: 4 -->
|
|
||||||
Затем разместите отряды, постройки, жетоны контроля над постройками, маркеры цели и жетоны варп-штормов на игровом поле, как показано на схеме. Не размещайте жетоны или отряды неиспользуемых фракций. Жетон подкрепления, указанный в «*Стартовых Силах*» орков, не помещается на игровое поле, а остается рядом с его листом фракции, пока не будет использован.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.game-board.first-game-layout; page: 4; kind: caption -->
|
|
||||||
#### Расстановка к первой игре
|
|
||||||
|
|
||||||
*Схема: игровое поле из трех рядов по четыре тайла систем. Верхний ряд — 10A, 1A, 11B, 2B; средний — 5A, 3B, 8A, 6B; нижний — 7A, 12A, 4B, 9A. «2 игрока» — два левых столбца, «3 игрока» — три левых столбца, «4 игрока» — все четыре столбца.*
|
|
||||||
|
|
||||||
<!-- block: rules.setup.game-board.custom-board-note; page: 5 -->
|
|
||||||
**Примечание:** Сыграв одну игру с использованием схемы «*Расстановка к Первой Игре*», игроки могут подготавливаться к игре, используя правила «*Создание Игрового Поля*» на стр. 16. Эти правила позволяют игрокам создавать уникальное игровое поле для каждой игры, выбирая, где размещать тайлы систем, отряды, жетоны целей и варп-штормы.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.decks; page: 5 -->
|
|
||||||
### 5. Подготовьте Колоды Событий, Улучшений и Боевые Колоды
|
|
||||||
|
|
||||||
Каждый игрок перемешивает свои карты событий и кладет их колодой лицевой стороной вниз в своей игровой зоне.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.decks.combat-deck; page: 5 -->
|
|
||||||
Затем каждый игрок находит десять боевых карт с символом своей фракции, напечатанным в верхнем левом углу, и перемешивает их, чтобы сформировать свою боевую колоду.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.decks.faction-symbol-caption; page: 5; kind: caption -->
|
|
||||||
Символ Фракции на Боевой Карте
|
|
||||||
|
|
||||||
<!-- block: rules.setup.decks.upgrade-decks; page: 5 -->
|
|
||||||
Наконец, каждый игрок формирует две колоды улучшений, используя все карты улучшения приказов своей фракции и оставшиеся боевые карты. Эти колоды не нужно тасовать. Игроки кладут их **лицевой стороной вверх** рядом со своим листом фракции, не смешивая их с боевой колодой.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.round-track; page: 5 -->
|
|
||||||
### 6. Подготовьте Счетчик Раундов
|
|
||||||
|
|
||||||
Поместите шкалу раундов рядом с игровым полем и поместите маркер раунда на ячейку «1» на шкале.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.supply; page: 5 -->
|
|
||||||
### 7. Создайте Запас
|
|
||||||
|
|
||||||
Разделите все жетоны подкреплений, жетоны запасов, жетоны кузницы, кубы и пластиковые постройки в кучки и положите их рядом с игровым полем, где все игроки могут их достать.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.ready; page: 5 -->
|
|
||||||
После завершения шагов подготовки к игре компоненты каждого игрока должны находиться в его игровой зоне, как показано ниже. Затем игроки готовы начать первый раунд игры.
|
|
||||||
|
|
||||||
<!-- block: rules.setup.play-area-example; page: 5; kind: caption -->
|
|
||||||
#### Пример игровой зоны
|
|
||||||
|
|
||||||
*Схема: игровая зона игрока — Колода Событий, Жетоны Приказов, Пластиковые Отряды, Счетчик Материалов, Маркеры Контроля Построек, Улучшения Приказов, Боевые Улучшения, Лист Фракции, Боевая Колода.*
|
|
||||||
|
|
||||||
<!-- block: rules.game-round; page: 5 -->
|
|
||||||
## Ход игры
|
|
||||||
|
|
||||||
Игра в *Forbidden Stars* состоит из серии игровых раундов. Каждый игровой раунд состоит из трех фаз, которые играются в следующем порядке:
|
|
||||||
|
|
||||||
1. **Фаза Планирования:** Во время этой фазы игроки по очереди размещают жетоны приказов на игровом поле.
|
|
||||||
2. **Фаза Действий:** Во время этой фазы игроки по очереди разыгрывают жетоны приказов, которые они разместили на игровом поле.
|
|
||||||
3. **Фаза Обновления:** Во время этой фазы игроки собирают цели, материалы, восстанавливают деморализованные отряды, перемещают варп-штормы и разыгрывают карты событий. Затем первый игрок передает жетон первого игрока и продвигает маркер раунда по счетчику раундов.
|
|
||||||
|
|
||||||
<!-- block: rules.game-round.next-round; page: 5 -->
|
|
||||||
После завершения Фазы Обновления игроки начинают новый раунд, начиная с новой Фазы Планирования. Они продолжают разыгрывать игровые раунды до тех пор, пока один из игроков не выиграет игру, собрав достаточное количество маркеров целей (подробно объяснено позже).
|
|
||||||
|
|
||||||
<!-- block: rules.planning; page: 5 -->
|
|
||||||
## Фаза 1: Фаза Планирования
|
|
||||||
|
|
||||||
Во время Фазы Планирования игроки по очереди кладут жетоны приказов лицевой стороной вниз на игровое поле. Игроки раскрывают эти жетоны и применяют их эффекты во время Фазы Действий.
|
|
||||||
|
|
||||||
<!-- block: rules.planning.placing-orders; page: 5 -->
|
|
||||||
Чтобы начать Фазу Планирования, первый игрок кладет один из своих жетонов приказов лицевой стороной вниз на зону для жетонов приказов на **тайле системы**. Затем по часовой стрелке каждый игрок кладет по одному жетону приказа на любой тайл системы. Игроки повторяют этот процесс до тех пор, пока каждый игрок не положит на игровое поле по четыре жетона приказов.
|
|
||||||
|
|
||||||
<!-- block: rules.planning.system-tile-diagram; page: 5; kind: caption -->
|
|
||||||
*Схема тайла системы:* Области Миров; Зона для Жетонов Приказов; Области Пустот.
|
|
||||||
|
|
||||||
<!-- block: rules.planning.order-stacks; page: 5 -->
|
|
||||||
Игроки кладут жетоны приказов **лицевой стороной вниз**, чтобы тип приказа был скрыт от других игроков. Если в системе уже есть один или несколько жетонов приказов, игрок кладет свой жетон приказа поверх стека существующих жетонов приказов. Жетон приказа на вершине стека всегда является жетоном приказа, который был помещен в эту систему последним.
|
|
||||||
|
|
||||||
<!-- block: rules.planning.order-stacks.captions; page: 5; kind: caption -->
|
|
||||||
Жетоны приказов размещаются лицевой стороной вниз на зону для жетонов приказов. Любое количество жетонов приказов могут быть размещены друг на друга формируя стек.
|
|
||||||
|
|
||||||
<!-- block: rules.planning.adjacency; page: 6 -->
|
|
||||||
**Важно:** Игрок не может положить жетон приказа в систему, если у него нет отрядов или построек либо в этой системе, либо в соседних с ней. Каждая система соседствует с системами, с которыми её тайл имеет общее ребро. Если у тайлов двух систем общий только угол, а не ребро, то они не являются соседними.
|
|
||||||
|
|
||||||
<!-- block: rules.planning.order-types; page: 6 -->
|
|
||||||
### Типы жетонов приказов
|
|
||||||
|
|
||||||
Игроки используют жетоны приказов, чтобы выполнять основные действия в *Forbidden Stars*, такие как перемещение отрядов, атака отрядами и производство новых отрядов и построек.
|
|
||||||
|
|
||||||
<!-- block: rules.planning.order-types.list; page: 6 -->
|
|
||||||
Существует четыре типа жетонов приказов. Когда игрок раскрывает жетон приказа, он разыгрывает эффект, соответствующий типу раскрытого жетона.
|
|
||||||
|
|
||||||
- **Развертывание:** Игрок может потратить свои материалы на покупку новых отрядов и построек в системе.
|
|
||||||
- **Планирование:** Игрок может купить улучшение приказа, боевое улучшение или оба. Затем он кладет жетон приказа на верх своей колоды событий, что позволяет ему взять карту события во время Фазы Обновления.
|
|
||||||
- **Доминирование:** Игрок получает ценные ресурсы из каждого дружественного мира в системе. Он также может использовать специальную способность на листе своей фракции.
|
|
||||||
- **Продвижение:** Игрок может переместить свои отряды в систему и начать одну битву.
|
|
||||||
|
|
||||||
Детали для выполнения каждого типа приказов подробно объяснены позже.
|
|
||||||
|
|
||||||
<!-- block: rules.action; page: 6 -->
|
|
||||||
## Фаза 2: Фаза Действий
|
|
||||||
|
|
||||||
Во время Фазы Действий игроки по очереди разыгрывают жетоны приказов, размещенные во время Фазы Планирования. Начиная с первого игрока и далее по часовой стрелке, каждый игрок выбирает один из своих жетонов приказов **сверху любого стека**. Он раскрывает выбранный жетон приказа, разыгрывает его эффект и убирает его с игрового поля. Фаза Действий заканчивается, когда игроки разыграли все жетоны приказов на игровом поле.
|
|
||||||
|
|
||||||
<!-- block: rules.action.resolve-or-event; page: 6 -->
|
|
||||||
Когда активный игрок раскрывает жетон приказа, он решает либо разыграть эффект жетона, либо положить жетон лицевой стороной вверх на верх своей колоды событий. Каждый жетон на колоде событий игрока позволяет ему взять одну карту события во время Фазы Обновления.
|
|
||||||
|
|
||||||
<!-- block: rules.action.no-available-order; page: 6 -->
|
|
||||||
**Примечание:** Если у игрока нет ни одного жетона приказа на вершине какого-либо стека, он обязан пропустить свой ход. Он обязан выполнить приказ во время своего следующего хода, если в этот момент на вершине какого-либо стека будет его приказ.
|
|
||||||
|
|
||||||
<!-- block: rules.units-and-structures; page: 6 -->
|
|
||||||
## Что такое отряды и постройки?
|
|
||||||
|
|
||||||
Наземные отряды и корабли — это типы **отрядов**, представленные пластиковыми фигурками. Игроки перемещают отряды, атакуют ими и размещают их, чтобы контролировать области на игровом поле, что в конечном счете позволяет им собирать свои маркеры целей. **Постройки** предоставляют игрокам боевые усиления (бастионы), позволяют игрокам покупать отряды (фабрики) и повышают командный уровень игрока (города), который необходим для приобретения карт улучшений и более мощных отрядов. Таким образом, для достижения победы игроку необходим тщательный баланс как построек, так и отрядов.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh; page: 6 -->
|
|
||||||
## Фаза 3: Фаза Обновления
|
|
||||||
|
|
||||||
Во время Фазы Обновления каждый игрок собирает материалы и маркеры целей из **дружественных** миров — миров, в которых есть только его отряды и/или постройки. Также во время этой фазы каждый игрок восстанавливает свои деморализованные отряды, чтобы они были готовы к следующему игровому раунду.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.steps; page: 6 -->
|
|
||||||
Во время Фазы Обновления каждый игрок выполняет следующие шаги по порядку:
|
|
||||||
|
|
||||||
1. Соберите Цели
|
|
||||||
2. Соберите Материалы
|
|
||||||
3. Восстановите Деморализованные Отряды
|
|
||||||
4. Возьмите События и Переместите Варп-Штормы
|
|
||||||
5. Конец Раунда
|
|
||||||
|
|
||||||
После завершения этой фазы игроки начинают новый игровой раунд.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.objectives; page: 6 -->
|
|
||||||
### Соберите Цели
|
|
||||||
|
|
||||||
Каждый игрок собирает любые **свои** маркеры **целей**, которые находятся в дружественных мирах. Он помещает маркеры в поле «Маркеры целей» на листе своей фракции, чтобы все игроки могли легко видеть, сколько маркеров он собрал.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.objectives.caption; page: 6; kind: caption -->
|
|
||||||
Игрок за Эльдар собрал маркер цели с дружественного мира.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.objectives.victory; page: 6 -->
|
|
||||||
Игрок выигрывает игру, если он собирает количество маркеров цели, равное количеству игроков в игре (см. «Победа в игре» ниже).
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.materials; page: 6 -->
|
|
||||||
### Соберите Материалы
|
|
||||||
|
|
||||||
У большинства миров есть **производственное значение**, которое обозначается числом на зеленом значке материалов.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.materials.caption; page: 6; kind: caption -->
|
|
||||||
Производственное значение этого мира равно двум
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.materials.gain; page: 7 -->
|
|
||||||
Во время Фазы Обновления каждый игрок получает количество материалов, равное сумме производственных значений всех его дружественных миров. Когда игрок получает материалы, он поворачивает счетчик материалов на соответствующую величину в большую сторону.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.materials.dial-caption; page: 7; kind: caption -->
|
|
||||||
Счетчик материалов установлен на значении «4».
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.rally; page: 7 -->
|
|
||||||
### Восстановите Деморализованные Отряды
|
|
||||||
|
|
||||||
Каждый игрок **восстанавливает** все свои **деморализованные** отряды. Отряды могут стать деморализованными во время битвы и кладутся на бок, чтобы это обозначить. Деморализованный отряд не может перемещаться или быть использованными для срабатывания способностей боевых карт. Он также не может привносить свои кубы или значение своего боевого духа в битву или орбитальный удар. Чтобы показать восстановление отряда, игрок возвращает его в исходное недеморализованное состояние: ставит фигурку ровно.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.rally.caption; page: 7; kind: caption -->
|
|
||||||
Недеморализованный Отряд; Деморализованный Отряд
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.events-and-warp-storms; page: 7 -->
|
|
||||||
### Возьмите События и Переместите Варп-Штормы
|
|
||||||
|
|
||||||
Каждый игрок берет из своей колоды событий количество карт, равное количеству жетонов приказов **на ней**, возвращая жетоны приказов в свой запас неиспользованных жетонов приказов. Жетоны приказов кладутся на верх этой колоды после выполнения Приказа Планирования (подробно объяснено позже).
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.events-and-warp-storms.choose-event; page: 7 -->
|
|
||||||
Затем, начиная с первого игрока и далее по часовой стрелке, каждый игрок выбирает одну карту события из тех, что он только что вытянул. Он перемещает один варп-шторм по игровому полю, следуя значку движения варп-шторма, изображенному на выбранной им карте (см. «Перемещение варп-шторма» справа). Затем он **может** разыграть способность карты события.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.events-and-warp-storms.event-types; page: 7 -->
|
|
||||||
Карты событий бывают двух видов: **тактика** и **план**.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.events-and-warp-storms.event-types.caption; page: 7; kind: caption -->
|
|
||||||
Карта Событий «Тактика»; Карта Событий «План»
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.events-and-warp-storms.tactic-and-scheme; page: 7 -->
|
|
||||||
Разыграв карту события-тактики, игрок замешивает ее обратно в свою колоду событий. Карты событий-планов размещаются лицом вверх рядом с листом фракции игрока, и их эффекты можно использовать в будущем, как описано на карте.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.events-and-warp-storms.reshuffle; page: 7 -->
|
|
||||||
После того, как игрок разыграет свою карту события, он замешивает все невыбранные карты событий из своей руки обратно в свою колоду.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.warp-storms; page: 7 -->
|
|
||||||
#### Что такое варп-штормы?
|
|
||||||
|
|
||||||
В *Forbidden Stars* жетоны варп-штормов размещаются на игровом поле вдоль ребер некоторых тайлов систем. Отряды не могут двигаться сквозь варп-штормы. Варп-штормы перемещаются в конце каждого игрового раунда, превращая игровое поле в постоянно меняющуюся и непредсказуемую среду.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.warp-storm-movement; page: 7 -->
|
|
||||||
#### Перемещение Варп-Штормов
|
|
||||||
|
|
||||||
Чтобы переместить варп-шторм, игрок выбирает один варп-шторм, **который не перемещали в этой фазе**. Затем он перемещает жетон в одном из двух направлений, указанных значком движения варп-шторма в правой части его карты события.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.warp-storm-movement.icon-caption; page: 7; kind: caption -->
|
|
||||||
Иконка Движения Варп-Шторма
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.warp-storm-movement.caption; page: 7; kind: caption -->
|
|
||||||
Существует восемь направлений, в которых может двигаться варп-шторм.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.warp-storm-movement.no-stacking; page: 7 -->
|
|
||||||
Игроки **не могут** переместить жетон варп-шторма на другой варп-шторм.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.warp-storm-movement.must-move; page: 7 -->
|
|
||||||
Когда идёт выбор, какой варп-шторм двигать и направление в котором его двигать, игрок всегда должен выбирать тот результат, при котором произойдёт движение варп-шторма, если это возможно.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.warp-storm-movement.no-events; page: 7 -->
|
|
||||||
**Важно:** Если игрок не брал карты событий, он все равно должен переместить варп-шторм. Для этого он раскрывает верхнюю карту своей колоды событий и перемещает один варп-шторм, используя значок движения варп-шторма на этой карте, как описано выше. Затем он замешивает карту события обратно в свою колоду, не разыгрывая ее способность.
|
|
||||||
|
|
||||||
<!-- block: rules.refresh.end-of-round; page: 8 -->
|
|
||||||
### Конец Раунда
|
|
||||||
|
|
||||||
Первый игрок отдает жетон первого игрока игроку слева от себя. Затем он передвигает маркер раунда на одно деление по счетчику раундов. Если маркер раунда сдвинется с деления «8» счетчика, игра заканчивается, и игрок с наибольшим количеством маркеров целей выигрывает игру.
|
|
||||||
|
|
||||||
<!-- block: rules.victory; page: 8 -->
|
|
||||||
## Победа в игре
|
|
||||||
|
|
||||||
Игрок выигрывает игру, когда он собрал количество своих маркеров целей, **равное количеству игроков в игре**. Например, во время игры для двух игроков игрок выигрывает игру, когда он собирает два своих маркера целей.
|
|
||||||
|
|
||||||
<!-- block: rules.victory.after-round-eight; page: 8 -->
|
|
||||||
Если ни один игрок не выиграл игру к концу восьмого игрового раунда, игра заканчивается, и игрок, собравший больше всего маркеров целей, побеждает в игре.
|
|
||||||
|
|
||||||
<!-- block: rules.victory.objective-markers; page: 8 -->
|
|
||||||
### Что такое маркеры целей?
|
|
||||||
|
|
||||||
Маркеры целей представляют собой важные объекты, людей или места, являющиеся ключевыми для выживания фракции. На обратной стороне каждого маркера цели показано, что тематически представляет этот маркер цели. Хотя у каждого маркера есть уникальное изображение, оно несет лишь тематический характер и **не влияет на игровой процесс**. На обратной стороне листа фракции каждого игрока есть краткое описание историй, связанных с его целями.
|
|
||||||
|
|
||||||
<!-- block: rules.victory.objective-markers.description-caption; page: 8; kind: caption -->
|
|
||||||
Описание Маркера Цели: «Лорд Системы: Лорд Халовар присягнул на верность Империуму. Теперь он просит помощи в избавлении от тьмы.»
|
|
||||||
|
|
||||||
<!-- block: rules.victory.objective-markers.face-up; page: 8 -->
|
|
||||||
Маркеры целей **всегда остаются лежать лицевой стороной вверх**, чтобы игроки могли видеть символ фракции. Это важно, потому что игрок не может собирать маркеры целей, принадлежащие другим игрокам. Однако игроки могут размещать отряды в мирах, содержащих вражеские маркеры целей, чтобы помешать своим противникам добиться победы.
|
|
||||||
|
|
||||||
<!-- block: rules.victory.objective-markers.face-caption; page: 8; kind: caption -->
|
|
||||||
Лицевая Сторона Маркера Цели
|
|
||||||
|
|
||||||
<!-- block: rules.orders; page: 8 -->
|
|
||||||
## Приказы подробно
|
|
||||||
|
|
||||||
Каждая фракция имеет по два жетона каждого из четырех типов приказов. Когда игрок выполняет приказ во время Фазы Действий, тайл системы, на котором был размещен приказ, становится **активной системой**. В этом разделе описывается, как игроки выполняют приказы каждого из четырех типов.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy; page: 8 -->
|
|
||||||
### Приказ Развертывания
|
|
||||||
|
|
||||||
Игроки используют Приказ Развертывания, чтобы размещать новые отряды и постройки на игровом поле. Чтобы выполнить Приказ Развертывания, активный игрок совершает следующие два шага **в указанном порядке**:
|
|
||||||
|
|
||||||
1. **Покупка Отрядов**: Если у игрока есть фабрика в активной системе, он может купить отряды и разместить их в любых дружественных или неконтролируемых (не содержащих никаких отрядов или построек) областях в активной системе.
|
|
||||||
2. **Покупка Построек**: Игрок может купить **одну** постройку и разместить ее в любом дружественном мире в активной системе, где еще нет постройки.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.token-caption; page: 8; kind: caption -->
|
|
||||||
Жетон Приказа Развертывания
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-units; page: 8 -->
|
|
||||||
#### Покупка Отрядов
|
|
||||||
|
|
||||||
Игрок покупает отряд, тратя количество материала, равное стоимости в материалах этого отряда, указанной на его листе фракции. Чтобы потратить материал, игрок поворачивает счетчик материалов на соответствующую величину в меньшую сторону. Затем он размещает отряд в любую **дружественную или неконтролируемую** область в активной системе.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-units.forge-tokens; page: 8 -->
|
|
||||||
Некоторые отряды стоят жетон кузницы в дополнение к их стоимости в материалах. Чтобы потратить жетон кузницы, игрок берет жетон кузницы из своей игровой зоны и возвращает его в запас.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-units.cost-caption; page: 8; kind: caption -->
|
|
||||||
*Схема строки отряда на листе фракции:* Материальная Стоимость Отряда; Требование Жетона Кузницы.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-units.placement; page: 8 -->
|
|
||||||
Игроки могут размещать наземные отряды только в мирах, а корабли — в пустотах. В дополнение к этим основным ограничениям игрок обязан соблюдать ограничения на командный уровень.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-units.deployment-limit; page: 8 -->
|
|
||||||
Максимальное количество отрядов, которое игрок может приобрести в рамках одного Приказа Развертывания, определяется его **лимитом развертывания**. Его лимит развертывания равен вместимости мира активной системы, где расположена фабрика. Вместимость равна количеству черепов на знамени этого мира.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-units.capacity-caption; page: 8; kind: caption -->
|
|
||||||
Вместимость отрядов этого мира равна двум.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.command-level; page: 9 -->
|
|
||||||
#### Командный уровень
|
|
||||||
|
|
||||||
У каждого игрока есть **командный уровень**, который указывает, какие отряды и улучшения он может купить. Командный уровень игрока равен количеству городов, которые он контролирует.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.command-level.requirements; page: 9 -->
|
|
||||||
У каждого отряда и карты улучшения приказа или боевой карты есть требование к командному уровню. Игрок может приобретать отряды и улучшения только с требованиями, равными его командному уровню или меньшими, чем его командный уровень.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.command-level.caption; page: 9; kind: caption -->
|
|
||||||
*Схема строки отряда на листе фракции:* Требуемый Командный Уровень Отряда.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-structures; page: 9 -->
|
|
||||||
#### Покупка Построек
|
|
||||||
|
|
||||||
Игрок покупает постройку (город, бастион или фабрику), потратив количество материалов, равное стоимости в материалах этой постройки, указанной на его листе фракции. Затем он размещает пластиковую постройку на любой дружественный мир в активной системе поверх одного из своих **маркеров контроля постройки**. Маркер под каждой постройкой указывает, кому принадлежит эта постройка.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-structures.one-per-world; page: 9 -->
|
|
||||||
**Важно:** Игрок не может разместить постройку в мире, в котором уже есть постройка.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.deploy.purchase-structures.caption; page: 9; kind: caption -->
|
|
||||||
Эта постройка контролируется игроком фракции Эльдар.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate; page: 9 -->
|
|
||||||
### Приказ Доминирования
|
|
||||||
|
|
||||||
Игроки используют Приказы Доминирования, чтобы получать ценные ресурсы с миров и использовать свои особые способности фракции. Чтобы выполнить Приказ Доминирования, активный игрок выполняет следующие шаги по порядку:
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.token-caption; page: 9; kind: caption -->
|
|
||||||
Жетон Приказа Доминирования
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.gain-resources; page: 9 -->
|
|
||||||
#### 1. Получение ценных ресурсов
|
|
||||||
|
|
||||||
Игрок получает ценные ресурсы, предоставленные каждым дружественным миром в активной системе. Ценные ресурсы отображаются в правом углу знамени мира.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.gain-resources.take-tokens; page: 9 -->
|
|
||||||
Игрок берет соответствующие жетоны из запаса и кладет их в свою игровую зону. Если мир предоставляет несколько ценных ресурсов, игрок берет их все.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.faction-ability; page: 9 -->
|
|
||||||
#### 2. Использование специальной способности фракции
|
|
||||||
|
|
||||||
Игрок может использовать специальную способность, указанную на листе его фракции.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources; page: 9 -->
|
|
||||||
#### Ценные ресурсы
|
|
||||||
|
|
||||||
Игроки могут получить ценные ресурсы с дружественных миров, выполнив Приказ Доминирования. Каждый ценный ресурс имеет уникальный эффект, как описано ниже:
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.forge; page: 9 -->
|
|
||||||
##### Жетоны Кузницы
|
|
||||||
|
|
||||||
Некоторые отряды требуют, чтобы игрок потратил жетон кузницы, чтобы купить их. Это отображается на листе фракции ниже стоимости соответствующего отряда в материалах.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.forge.command-level; page: 9 -->
|
|
||||||
В качестве альтернативы игрок может потратить жетон кузницы при покупке отряда, чтобы снизить требование к его командному уровню на **один**. Это позволяет ему купить отряд, который при нормальных условиях не может быть куплен (см. «Командный уровень» слева).
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.forge.caption; page: 9; kind: caption -->
|
|
||||||
Этот мир предоставляет жетон кузницы.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.supply; page: 9 -->
|
|
||||||
##### Жетоны Запасов
|
|
||||||
|
|
||||||
При покупке отряда или постройки игрок может потратить жетон запасов, чтобы снизить стоимость в материалах этого отряда или постройки на два.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.supply.caption; page: 9; kind: caption -->
|
|
||||||
Этот мир предоставляет жетон запасов.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.reinforcement; page: 9 -->
|
|
||||||
##### Жетоны Подкрепления
|
|
||||||
|
|
||||||
Во время шага вызова подкреплений во время битвы атакующий, а затем защищающийся могут разместить свои жетоны подкреплений в оспариваемую область.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.reinforcement.unit; page: 9 -->
|
|
||||||
Жетон подкрепления остается на время битвы и считается наземным отрядом или кораблем соответствующей фракции нулевого командного уровня. Находясь в области, он соблюдает все правила и ограничения, применимые к отрядам.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.reinforcement.example; page: 9 -->
|
|
||||||
Например, каждый жетон подкрепления, который игрок Ультрамаринов размещает в мире, рассматривается как отряд Разведчиков.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.reinforcement.caption; page: 9; kind: caption -->
|
|
||||||
Этот мир предоставляет жетон подкрепления.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.prosperity; page: 9 -->
|
|
||||||
##### Процветание
|
|
||||||
|
|
||||||
Когда игрок получает этот ценный ресурс, он вместо этого получает один жетон ресурса по своему выбору.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.dominate.resources.prosperity.caption; page: 9; kind: caption -->
|
|
||||||
Этот мир предоставляет один жетон ценного ресурса любого типа
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize; page: 10 -->
|
|
||||||
### Приказ Планирования
|
|
||||||
|
|
||||||
Чтобы выполнить Приказ Планирования у игрока должен быть **отряд или постройка в активной системе**. Он может просмотреть все карты в своих колодах улучшений. Затем он может купить одно улучшение приказа и/или одно боевое улучшение, если он соответствует ограничениям командного уровня каждой карты (см. «Командный уровень» на стр. 9). После выполнения этого приказа он кладет жетон приказа **на верх своей колоды событий**, что позволит ему взять карту события во время Фазы Обновления.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.token-caption; page: 10; kind: caption -->
|
|
||||||
Жетон Приказа Планирования
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.upgrade-cost; page: 10 -->
|
|
||||||
Чтобы купить улучшение, игрок должен потратить количество материалов, равное стоимости в материалах, указанной в левом верхнем углу карты улучшения приказа или боевой колоды. Чтобы потратить материалы, игрок поворачивает счетчик материалов на соответствующую величину в меньшую сторону.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.upgrade-cost.caption; page: 10; kind: caption -->
|
|
||||||
*Схема карты улучшения:* Требуемый Командный Уровень Улучшения; Материальная Стоимость Улучшения.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.upgrade-types; page: 10 -->
|
|
||||||
Существует два типа улучшений: **улучшения приказов** и **боевые улучшения**, а именно:
|
|
||||||
|
|
||||||
- **Улучшения Приказов:** Улучшения приказов повышают функциональность жетонов приказов. После покупки улучшения приказа игрок помещает его в свою игровую зону рядом со своим листом фракции. Каждое улучшение приказа соответствует одному из четырех приказов (Развертывание, Планирование, Доминирование, Продвижение) и дает игроку преимущество при выполнении соответствующего приказа.
|
|
||||||
- **Боевые Улучшения:** Боевые улучшения — это более мощные боевые карты, которые игрок добавляет в свою боевую колоду.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.upgrade-types.caption; page: 10; kind: caption -->
|
|
||||||
Карта Улучшения Приказов («Крестовый поход»); Карта Боевого Улучшения («Держать строй»)
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.combat-upgrades; page: 10 -->
|
|
||||||
#### Покупка боевых улучшений
|
|
||||||
|
|
||||||
Игрок покупает карты боевых улучшений парами. Когда игрок покупает одну карту боевого улучшения, он получает **обе копии карты**.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.combat-upgrades.swap; page: 10 -->
|
|
||||||
Когда игрок покупает пару боевых карт, он должен убрать две копии любой другой карты из своей боевой колоды и поместить их обе в свою колоду боевых улучшений. Затем он добавляет обе копии купленного улучшения в свою боевую колоду и перемешивает колоду.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.strategize.combat-upgrades.deck-size; page: 10 -->
|
|
||||||
Боевая колода всегда содержит две копии пяти карт, что в сумме дает десять карт.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance; page: 10 -->
|
|
||||||
### Приказ Продвижения
|
|
||||||
|
|
||||||
Игроки используют Приказы Продвижения для перемещения отрядов и начала битв. Чтобы выполнить Приказ Продвижения, игрок перемещает отряды **в активную систему**. После перемещения отрядов игрок разрешает битву, если есть **оспариваемая область** — область, в которой есть как дружественные, так и вражеские отряды или постройки.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.token-caption; page: 10; kind: caption -->
|
|
||||||
Жетон Приказа Продвижения
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.steps; page: 10 -->
|
|
||||||
Чтобы выполнить Приказ Продвижения, активный игрок совершает следующие шаги по порядку:
|
|
||||||
|
|
||||||
1. **Перемещение Кораблей:** Активный игрок может переместить свои корабли в активной системе и из одной соседней системы в любые пустоты в активной системе.
|
|
||||||
2. **Перемещение Наземных Отрядов:** Активный игрок может переместить свои наземные отряды в активной системе и из одной соседней системы в любые миры в активной системе. Если он перемещал корабли из соседней системы, он не может перемещать наземные отряды из другой соседней системы.
|
|
||||||
3. **Разрешение Битвы:** Активный игрок разрешает битву, если есть оспариваемая область (см. «Битва» на стр. 12). Если оспариваемой области нет, он может вместо этого провести орбитальный удар (см. «Орбитальный удар» на стр. 11).
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.limits; page: 10 -->
|
|
||||||
**Важно:** Отряды **не могут двигаться через варп-штормы**, и игрок может создать **максимум одну** оспариваемую область при выполнении Приказа Продвижения. Игрок может переместить любое количество отрядов Приказом Продвижения, но **максимум пять отрядов** могут закончить свое перемещение в каждой области.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.ship-movement; page: 10 -->
|
|
||||||
#### Перемещение Кораблей
|
|
||||||
|
|
||||||
При перемещении корабля игрок может переместить корабль из пустоты, которую он в данный момент занимает, в любую пустоту в активной системе, даже если эти две области не являются соседними.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.ground-movement; page: 10 -->
|
|
||||||
#### Перемещение Наземных Отрядов
|
|
||||||
|
|
||||||
При перемещении наземного отряда игрок может переместить отряд из мира, в котором он находится в данный момент, в любой мир в активной системе, соединенной **путем**. Путь представляет собой ряд соседних (не по диагонали) дружественных областей. Путь может состоять из миров, пустот или их сочетаний (см. «Пример Приказа Продвижения» на стр. 11).
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.ground-movement.adjacent-worlds; page: 10 -->
|
|
||||||
Наземные отряды могут перемещаться из одного мира в соседний без использования корабля. Тематически они используют невоенные транспортные корабли или другие простые формы передвижения, не представленные в игре. Количество отрядов, которые игрок может перемещать через дружественную область, не ограничено.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.capacity; page: 10 -->
|
|
||||||
#### Вместимость Отрядов
|
|
||||||
|
|
||||||
Каждая область имеет **вместимость**. Вместимость пустоты равна трем, а вместимость каждого мира равна количеству значков черепа, представленных на знамени мира. Вместимость области указывает **максимальное количество отрядов**, которые могут существовать в области после полного выполнения приказа.
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.capacity.caption; page: 10; kind: caption -->
|
|
||||||
Вместимость Отрядов Мира
|
|
||||||
|
|
||||||
<!-- block: rules.orders.advance.example; page: 11; kind: example -->
|
|
||||||
#### Пример Приказа Продвижения
|
|
||||||
|
|
||||||
1. Настала очередь игрока за Ультрамаринов разыгрывать приказ, поэтому он раскрывает один из своих Приказов Продвижения переворачивая его лицевой стороной вверх.
|
|
||||||
2. Сначала он должен переместить корабли. У него нет кораблей в активной системе, поэтому он перемещает Ударный Крейсер из соседней системы в пустоту в активной системе.
|
|
||||||
3. Затем игрок решает переместить некоторые из своих наземных отрядов из соседней системы в активную систему. Теперь, когда у него есть корабль в пустоте, он может перемещать через нее наземные отряды. Он перемещает своих Космодесантников через пустоту в мир в правом верхнем углу активной системы.
|
|
||||||
4. Затем игрок перемещает своего Разведчика через пустоту в мир в левом нижнем углу активной области.
|
|
||||||
5. Наконец, игрок решает переместить своего Разведчика, который уже был в активной системе, в соседний мир в правом нижнем углу.
|
|
||||||
|
|
||||||
<!-- block: rules.orbital-strike; page: 11 -->
|
|
||||||
## Орбитальный удар
|
|
||||||
|
|
||||||
После выполнения Приказа Продвижения, если нет оспариваемых областей (и, следовательно, битва не начиналась), активный игрок может выполнить орбитальный удар.
|
|
||||||
|
|
||||||
<!-- block: rules.orbital-strike.procedure; page: 11 -->
|
|
||||||
Чтобы выполнить орбитальный удар, игрок выбирает вражеский мир в активной системе. Затем он выбирает одну пустоту в активной системе, соседнюю этому миру. Он бросает количество кубиков, равное общей боевой мощи всех его кораблей в этой пустоте.
|
|
||||||
|
|
||||||
<!-- block: rules.orbital-strike.damage; page: 11 -->
|
|
||||||
Вражеские отряды в выбранном мире получают урон, равный количеству выпавших значков атаки ([куб: атака]). Боевая мощь и получение урона подробно объясняются позже.
|
|
||||||
|
|
||||||
<!-- block: rules.orbital-strike.bastion; page: 11 -->
|
|
||||||
Игрок не может выполнить орбитальный удар по миру, содержащему бастион. Бастионы описаны позже.
|
|
||||||
|
|
||||||
<!-- block: rules.battle; page: 12 -->
|
|
||||||
## Битва
|
|
||||||
|
|
||||||
После того, как игрок перемещает отряды с помощью Приказа Продвижения, он должен разыграть битву, если у него есть отряды в области, содержащей как дружественные, так и вражеские отряды или постройки (т. е. оспариваемой области). Во время битвы игрок, выполняющий Приказ Продвижения, является **атакующим**, а другой игрок - **защищающимся**.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.overview; page: 12 -->
|
|
||||||
В битвах в *Forbidden Stars* используется комбинация кубов и карт. Игроки начинают битву, бросая кубы в зависимости от боевой мощи своих отрядов. Затем они разыгрывают боевые карты, чтобы активировать специальные способности и получить боевые знаки, кубы и боевые жетоны. Игрок с наибольшим значением боевого духа ([мораль]) в конце битвы становится победителем, а его противник должен отступить.
|
|
||||||
|
|
||||||
Битва состоит из следующих шагов:
|
|
||||||
|
|
||||||
<!-- block: rules.battle.preparation; page: 12 -->
|
|
||||||
### 1. Подготовка
|
|
||||||
|
|
||||||
Игроки готовятся к битве, выполняя следующие подшаги:
|
|
||||||
|
|
||||||
<!-- block: rules.battle.preparation.roll-dice; page: 12 -->
|
|
||||||
#### а. Бросьте Кубы
|
|
||||||
|
|
||||||
И нападающий, и защищающийся одновременно бросают количество кубов, равное суммарной **боевой мощи** всех его недеморализованных отрядов в бою. Боевая мощь отрядов игрока указана на его листе фракции. Например, если игрок за Ультрамаринов имеет в бою два Ленд Рейдера, он бросает шесть кубов.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.preparation.roll-dice.caption; page: 12; kind: caption -->
|
|
||||||
Боевая Мощь Отряда На Листе Фракции
|
|
||||||
|
|
||||||
<!-- block: rules.battle.preparation.roll-dice.group; page: 12 -->
|
|
||||||
Каждый игрок распределяет свои брошенные кубы по группам с одинаковыми значками, чтобы и он, и его противник могли легко оценить результаты.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.preparation.draw-cards; page: 12 -->
|
|
||||||
#### б. Возьмите Боевые Карты
|
|
||||||
|
|
||||||
Каждый игрок берет пять боевых карт с верха своей боевой колоды.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.preparation.reinforcements; page: 12 -->
|
|
||||||
#### в. Шаг Вызова Подкреплений
|
|
||||||
|
|
||||||
Сначала у атакующего, а затем у защищающегося есть возможность разместить несколько своих жетонов подкреплений рядом со своими отрядами в оспариваемой области. Количество жетонов подкреплений, которое может разместить игрок, равно количеству его отрядов в битве (см. «Ценные ресурсы» на стр. 9).
|
|
||||||
|
|
||||||
<!-- block: rules.battle.round; page: 12 -->
|
|
||||||
### 2. Раунд битвы
|
|
||||||
|
|
||||||
Игроки играют до трех раундов битвы, разыгрывая боевые карты со своих рук. Во время **каждого раунда битвы** игроки выполняют следующие подшаги:
|
|
||||||
|
|
||||||
<!-- block: rules.battle.round.choose-card; page: 12 -->
|
|
||||||
#### а. Выберите Боевую Карту
|
|
||||||
|
|
||||||
Атакующий и защищающийся одновременно выбирают по одной боевой карте с руки и кладут выбранную карту лицевой стороной вниз в игровую зону.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.round.resolve-card; page: 12 -->
|
|
||||||
#### б. Разыграйте Боевую Карту
|
|
||||||
|
|
||||||
Атакующий раскрывает и разыгрывает свою боевую карту. Затем защитник раскрывает и разыгрывает свою боевую карту, как описано на стр. 13.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.round.damage; page: 12 -->
|
|
||||||
#### в. Шаг Нанесения Повреждений
|
|
||||||
|
|
||||||
Начиная с атакующего, оба игрока должны получить урон, равный общему количеству значков атаки его противника ([атака]) с кубов, боевых жетонов и открытых боевых карт. Каждый значок защиты ([защита]), который есть у игрока (с кубов, боевых жетонов и его открытых боевых карт), уменьшает количество получаемого урона на один. Правила получения урона объясняются на стр. 13.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.round.damage.caption; page: 12; kind: caption -->
|
|
||||||
Игрок Ультрамаринов в общей сложности имеет три знака атаки ([атака]) и четыре защиты ([защита]).
|
|
||||||
|
|
||||||
<!-- block: rules.battle.round.damage.sole-survivor; page: 12 -->
|
|
||||||
После того, как оба игрока получили урон, если только у одного игрока остались отряды и/или бастионы в области, он побеждает в битве и переходит к подэтапу «Захватите постройки», как описано ниже.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolution; page: 12 -->
|
|
||||||
### 3. Разрешение
|
|
||||||
|
|
||||||
После завершения третьего раунда битвы игроки заканчивают битву, выполнив следующие подшаги:
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolution.determine-winner; page: 12 -->
|
|
||||||
#### а. Определите Победителя
|
|
||||||
|
|
||||||
Игрок с наибольшим суммарным значением боевого духа побеждает в бою (защитник побеждает при ничье), а вражеские отряды должны отступить (объясняется позже).
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolution.determine-winner.total-morale; page: 12 -->
|
|
||||||
Игрок определяет свой суммарный боевой дух, складывая количество значков своего боевого духа ([мораль]) на своих кубах и боевых картах, лежащих лицом вверх, а также значения боевого духа и на листе своей фракции, которые соответствуют каждому из его бастионов и недеморализованных отрядов в битве.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolution.determine-winner.caption; page: 12; kind: caption -->
|
|
||||||
Боевой Дух Отряда На Листе Фракции
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolution.capture-structures; page: 12 -->
|
|
||||||
#### б. Захватите Постройки
|
|
||||||
|
|
||||||
Если атакующий побеждает в битве, он получает контроль над всеми постройками в мире, убирая маркеры контроля построек противника и заменяя их своими собственными.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolution.cleanup; page: 12 -->
|
|
||||||
#### в. Очистка
|
|
||||||
|
|
||||||
Каждый игрок сбрасывает все жетоны подкреплений, которые он использовал, в запас и замешивает все свои боевые карты обратно в свою боевую колоду.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolving-cards; page: 13 -->
|
|
||||||
### Розыгрыш Боевых Карт
|
|
||||||
|
|
||||||
Все боевые карты имеют одну или две ячейки боевых способностей. Разыгрывая боевую карту, игрок сначала применяет все общие способности (зеленая ячейка), а затем применяет все способности отрядов (коричневая ячейка). Он применяет каждую способность в том порядке, в котором она указана, сверху вниз.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolving-cards.unit-abilities; page: 13 -->
|
|
||||||
Способностям отрядов всегда предшествует хотя бы одно имя отряда. Чтобы использовать способность отрядов, игрок должен иметь хотя бы один из перечисленных отрядов в бою, и этот отряд должен быть недеморализованным.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.resolving-cards.caption; page: 13; kind: caption -->
|
|
||||||
*Схема боевой карты:* Общая Способность — «Получите 2 [жетон: защита].»; Способность Отрядов — «Бастион/Космодесантник/Ударный Крейсер: Переверните до 2 ваших кубов на [куб: защита].»
|
|
||||||
|
|
||||||
Чтобы воспользоваться способностью отрядов, у игрока должен быть хотя бы один Бастион или недеморализованный Космодесантник или недеморализованный Ударный Крейсер в битве.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.card-icons; page: 13 -->
|
|
||||||
### Значки Боевых Карт
|
|
||||||
|
|
||||||
Большинство боевых карт содержат один или несколько боевых значков ([атака], [защита], [мораль]) в левой части карты. Эти значки действуют так же, как значки на кубах, и сохраняются до конца битвы.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.card-icons.caption; page: 13; kind: caption -->
|
|
||||||
Значки на Боевой Карте («Не показывать страха»)
|
|
||||||
|
|
||||||
<!-- block: rules.battle.dice; page: 13 -->
|
|
||||||
### Боевые Кубы
|
|
||||||
|
|
||||||
Когда игрок получает [куб: атака], [куб: защита] или [куб: мораль] от способности, он берет один кубик из запаса неиспользованных кубов и кладет его рядом с другими кубами указанным значком вверх. Когда игрок теряет куб, он берет один из своих кубов с соответствующим значком и возвращает его в запас.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.dice.roll-extra; page: 13 -->
|
|
||||||
Если игровой эффект позволяет игроку получить [куб], он берет куб из запаса, бросает его и кладет его вместе с другими своими кубами.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.dice.limit; page: 13 -->
|
|
||||||
**Важно:** Атакующий и защищающийся ограничены **восьмью кубами** во время битвы.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.tokens; page: 13 -->
|
|
||||||
### Боевые Жетоны
|
|
||||||
|
|
||||||
Некоторые способности боевых карт предоставляют игрокам боевые жетоны, представленные значками боевых жетонов ([жетон: атака] и [жетон: защита]). Когда игрок получает [жетон: атака] или [жетон: защита], он берет боевой жетон из резерва и кладет его соответствующей стороной вверх рядом со своими кубами. При расчете атаки или защиты во время битвы боевые жетоны игрока добавляются к его окончательному значению. Все боевые жетоны являются **временными** и возвращаются в запас в конце раунда битвы, в котором они были получены.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.tokens.caption; page: 13; kind: caption -->
|
|
||||||
Жетоны Сражения
|
|
||||||
|
|
||||||
<!-- block: rules.battle.taking-damage; page: 13 -->
|
|
||||||
### Получение Урона
|
|
||||||
|
|
||||||
Когда игрок получает урон, он обязан выбрать один дружественный отряд в оспариваемой области, который получит урон. Этот отряд должен быть недеморализованным, кроме ситуаций, когда все отряды деморализованы. Если урон равен **показателю здоровья** выбранного отряда или превышает его, этот отряд **уничтожается** и удаляется с игрового поля.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.taking-damage.caption; page: 13; kind: caption -->
|
|
||||||
Показатель Здоровья Отряда на Листе Фракции
|
|
||||||
|
|
||||||
<!-- block: rules.battle.taking-damage.excess; page: 13 -->
|
|
||||||
Если урон ниже показателя здоровья выбранного отряда, этот отряд становится деморализованным. Если урон **превышает** показатель здоровья выбранного отряда, любой урон, превышающий показатель здоровья отряда, должен быть нанесен другому дружественному отряду. Игрок повторяет этот процесс до тех пор, пока либо не останется урона, либо не останется больше отрядов.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.taking-damage.orbital-strike; page: 13 -->
|
|
||||||
**Важно:** При получении урона во время орбитального удара любые неуничтоженные отряды **не становятся деморализованными**.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.taking-damage.example; page: 13; kind: example -->
|
|
||||||
*Пример:* У игрока за Ультрамаринов в битве два Разведчика. В течение первого раунда битвы он должен получить три урона. Он выбирает одного из своих Разведчиков, который первым получит урон. Он сравнивает свои два здоровья с тремя единицами урона; поскольку показатель здоровья Разведчика равно или меньше урона, он уничтожается. Затем игрок назначает один оставшийся урон другому своему Разведчику. Этот отряд не уничтожается, а становится деморализованным, а урон отбрасывается, потому что показатель здоровья отряда превышает урон.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.routed-units; page: 13 -->
|
|
||||||
### Деморализованные Отряды
|
|
||||||
|
|
||||||
Отряды могут стать **деморализованными** из-за способностей боевых карт, отступления или получения урона, который меньше их показателей здоровья. При деморализации отряда пластиковая модель кладется на бок. Если отряд представлен жетоном подкрепления, этот жетон переворачивается на деморализованную сторону.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.routed-units.effects; page: 13 -->
|
|
||||||
Деморализованные отряды не добавляют значение своего боевого духа ([мораль]) при определении победителя боя и не могут удовлетворять требованиям отрядов на боевой карте. Кроме того, игрок **не может назначать урон** деморализованному отряду, если у него есть один или несколько недеморализованных отрядов или бастионов в области. Во время Фазы Обновления все деморализованные отряды восстанавливаются: пластиковые модели поднимаются вертикально.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.bastions; page: 13 -->
|
|
||||||
### Бастионы
|
|
||||||
|
|
||||||
Бастионы не являются отрядами, но они добавляют кубы и свои значения боевого духа во время битвы. Кроме того, бастионы могут быть выбраны для получения урона. Если бастион получает урон, равный показателю его здоровья, он удаляется с игрового поля и возвращается в запас. Бастионы не могут стать деморализованными.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.example-part-1; page: 14; kind: example -->
|
|
||||||
### Пример битвы часть I
|
|
||||||
|
|
||||||
1. Игрок за Ультрамаринов (синий) выполняет Приказ Продвижения. После перемещения своих отрядов у него есть два отряда в области с двумя эльдарскими отрядами. Игроки за Ультрамаринов и Эльдар теперь должны разрешить битву.
|
|
||||||
2. Игроки сверяются с листами своих фракций, чтобы определить, сколько кубов дают их отряды. Оба игрока одновременно бросают свои кубы.
|
|
||||||
|
|
||||||
Затем каждый игрок берет пять карт из своей боевой колоды и держит эти карты скрытыми друг от друга.
|
|
||||||
|
|
||||||
**Первый раунд битвы:** Каждый игрок выбирает боевую карту из своей руки и кладет ее лицом вниз перед собой. Затем игроки выполняют следующие шаги:
|
|
||||||
|
|
||||||
3. Игрок за Ультрамаринов является атакующим, поэтому он первым раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему два боевых жетона защиты.
|
|
||||||
4. Затем он применяет способность отрядов в коричневой ячейке. У него есть отряд Космодесантников, необходимый для применения этой способности, поэтому он решает перевернуть один из своих [куб: атака] кубов в [куб: защита] куб.
|
|
||||||
5. Игрок за Эльдар раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему один куб. Он бросает куб и кладет его рядом с остальными кубами.
|
|
||||||
6. Затем игрок за Эльдар применяет способность отрядов в коричневой ячейке. У него есть Аспектный Воин, и он решает потратить [куб: мораль] куб, чтобы вынудить своего противника выбрать и деморализовать один свой отряд. Игрок за Ультрамаринов решает деморализовать одного из своих Разведчиков.
|
|
||||||
|
|
||||||
В качестве последнего шага этого раунда битвы игроки должны получить урон. Каждый игрок подсчитывает количество имеющихся у него боевых значков на своих кубах, своих боевых картах и своих боевых жетонах.
|
|
||||||
|
|
||||||
Ни один из игроков не имеет больше значков атаки ([атака]), чем значков защиты его противника ([защита]), поэтому ни один из игроков не получает урон.
|
|
||||||
|
|
||||||
Игроки сбрасывают все боевые жетоны, затем проводят следующий раунд битвы(см. «Пример битвы, часть II» на стр. 15).
|
|
||||||
|
|
||||||
<!-- block: rules.battle.example-part-2; page: 15; kind: example -->
|
|
||||||
### Пример битвы часть II
|
|
||||||
|
|
||||||
**Второй Раунд Битвы:** Каждый игрок выбирает боевую карту из своей руки и кладет ее лицом вниз перед собой. Затем игроки выполняют следующие шаги:
|
|
||||||
|
|
||||||
7. Игрок за Ультрамаринов раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему один куб. Он бросает куб и кладет его рядом с остальными своими кубами.
|
|
||||||
8. Затем он применяет способность отрядов в коричневой ячейке. У него есть отряд Космодесантников, необходимый для применения этой способности, поэтому он выбирает восстановить своего Разведчика.
|
|
||||||
9. Затем игрок за Эльдар раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему два боевых жетона атаки.
|
|
||||||
10. Теперь игрок за Эльдар может применить способность в коричневой ячейке, но он решает это не делать.
|
|
||||||
11. Каждый игрок подсчитывает количество значков атаки ([атака]) на его кубах, боевых картах и боевых жетонах.
|
|
||||||
|
|
||||||
Игрок за Эльдар — единственный игрок, у которого больше значков атаки ([атака]), чем значков защиты ([защита]) его противника. У него шесть значков атаки, а у его противника — два значка защиты, поэтому его противник получает четыре урона.
|
|
||||||
12. Игрок за Ультрамаринов решает сначала распределить урон на Разведчика. У него всего два здоровья и он уничтожен. Остальные два урона распределяются на другой его отряд, у которого три здоровья. Этого урона недостаточно для его уничтожения, поэтому отряд становится деморализованным.
|
|
||||||
|
|
||||||
Игроки сбрасывают все боевые жетоны. Поскольку у обоих игроков остались отряды, они разыгрывают еще один раунд битвы. Если у обоих игроков есть хотя бы по одному выжившему отряду, игрок с наибольшим суммарным значением боевого духа в конце этого раунда битвы побеждает в битве, а его противник должен отступить всеми своими отрядами.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.retreat; page: 15 -->
|
|
||||||
### Отступление
|
|
||||||
|
|
||||||
Отряды должны отступить, когда они проигрывают битву. Кроме того, некоторые способности боевых карт могут заставить отряды отступить. После отступления отряды становятся деморализованными.
|
|
||||||
Атакующие и защищающиеся отряды следуют разным правилам отступления, а именно:
|
|
||||||
|
|
||||||
<!-- block: rules.battle.retreat.attacker; page: 15 -->
|
|
||||||
#### Атакующий отступает
|
|
||||||
|
|
||||||
Когда атакующий отступает, он должен переместить все свои оставшиеся отряды из оспариваемой области в другую область — корабли должны отступить в пустоту, а наземные отряды должны отступить в мир по легальному пути перемещения.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.retreat.attacker.same-area; page: 15 -->
|
|
||||||
Все отступающие отряды обязаны переместиться в одну и ту же область, и это должна быть область, из которой изначально переместился хотя бы один из отрядов в битве.
|
|
||||||
|
|
||||||
<!-- block: rules.battle.retreat.defender; page: 15 -->
|
|
||||||
#### Защищающийся отступает
|
|
||||||
|
|
||||||
Когда защищающийся отступает, он должен переместить все свои оставшиеся отряды из оспариваемой области в дружественную или неконтролируемую область в активной системе или соседней системе — корабли должны отступить в пустоту, а наземные отряды должны отступить в мир по легальному пути перемещения. Все отступающие отряды должны переместиться в одну и ту же область. Если защищающийся может отступить как в дружественную, так и в неконтролируемую область, он обязан отступить в дружественную область.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.stop; page: 16 -->
|
|
||||||
## Стоп!
|
|
||||||
|
|
||||||
Теперь вы знаете все правила, необходимые для вашей первой игры. Поиграв со схемой *«Расстановка к Первой Игре»*, вы готовы изучить дополнительные правила на этой странице.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building; page: 16 -->
|
|
||||||
## Создание игрового поля
|
|
||||||
|
|
||||||
После игры с использованием игрового поля, представленного на схеме *«Расстановка к Первой Игре»*, игроки готовы играть, создав уникальное игровое поле во время подготовки к игре самостоятельно.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.steps; page: 16 -->
|
|
||||||
Сборка игрового поля — это стратегическое упражнение, в котором игроки выбирают, где размещать тайлы системы и где размещать свои первоначальные силы. На шаге подготовки к игре «Сборка игрового поля» игроки выполняют следующие действия:
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.distribute-tiles; page: 16 -->
|
|
||||||
### 1. Распределите Тайлы Систем
|
|
||||||
|
|
||||||
Каждый игрок получает тайл системы, на котором есть значок его фракции. Затем первый игрок берет все тайлы систем, на которых нет значков фракций, перемешивает их под столом и раздает по два каждому игроку.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.distribute-objectives; page: 16 -->
|
|
||||||
### 2. Распределите маркеры целей
|
|
||||||
|
|
||||||
Каждый игрок отдает по два своих маркера целей каждому другому игроку и возвращает оставшиеся маркеры в коробку с игрой.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.assemble; page: 16 -->
|
|
||||||
### 3. Соберите игровое поле
|
|
||||||
|
|
||||||
Начиная с первого игрока и далее по **часовой стрелке**, каждый игрок размещает один тайл системы в игровой зоне следующим образом:
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.assemble.place-tile; page: 16 -->
|
|
||||||
#### а. Разместите Тайл Системы
|
|
||||||
|
|
||||||
Игрок выбирает один из своих тайлов системы и кладет его рядом хотя бы с одним другим тайлом системы (см. «Пример размещения тайла» справа).
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.assemble.place-forces; page: 16 -->
|
|
||||||
#### б. Разместите Отряды и Постройки
|
|
||||||
|
|
||||||
Игрок может разместить любое количество компонентов со своего листа фракции (в ячейке «Стартовые силы») на тайл, который он только что разместил.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.assemble.place-objectives; page: 16 -->
|
|
||||||
#### в. Разместите Маркеры Целей
|
|
||||||
|
|
||||||
Игрок **обязан** разместить один из маркеров целей противника **на каждую ячейку для маркеров целей** на только что размещенном тайле системы, соблюдая следующие ограничения:
|
|
||||||
|
|
||||||
- Игрок не может размещать два маркера одной и той же фракции на одном тайле системы.
|
|
||||||
- Игрок не может разместить маркер цели, принадлежащий одной фракции, если у него больше маркеров цели, принадлежащих другой фракции.
|
|
||||||
- После того, как он разместил все тайлы, полученные на шаге 2, он больше не размещает маркеров целей.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.assemble.objective-zone-caption; page: 16; kind: caption -->
|
|
||||||
Зона Размещения Маркера Целей
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.assemble.repeat; page: 16 -->
|
|
||||||
Игроки повторяют этот процесс до тех пор, пока каждый игрок не поместит все свои тайлы системы и все свои компоненты, перечисленные в поле «Стартовые силы» на листе своей фракции.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.warp-storms; page: 16 -->
|
|
||||||
### 4. Разместите Варп-Штормы
|
|
||||||
|
|
||||||
Начиная с игрока, **разместившего последнюю систему**, и далее **против часовой стрелки**, каждый игрок размещает один варп-шторм вдоль любого ребра любого тайла системы; это может быть внешнее ребро - край игрового поля.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.tile-placement; page: 16 -->
|
|
||||||
### Размещение Тайлов Системы
|
|
||||||
|
|
||||||
При размещении тайла системы игрок может разместить либо тайл своей фракции, либо любой другой тайл. Он может положить тайл любой стороной вверх и в любой ориентации; однако после того, как первый игрок поместит свой первый тайл системы, все остальные тайлы должны быть размещены **по соседству** с другим тайлом.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.tile-placement.max-size; page: 16 -->
|
|
||||||
Игрок **не может размещать тайл** таким образом, чтобы он нарушал максимальный размер игрового поля. Этот размер варьируется в зависимости от количества игроков:
|
|
||||||
|
|
||||||
- **Два Игрока:** Три тайла на два тайла
|
|
||||||
- **Три Игрока:** Три тайла на три тайла
|
|
||||||
- **Четыре Игрока:** Три тайла на четыре тайла
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.tile-placement.orientation; page: 16 -->
|
|
||||||
Ориентация игрового поля не определяется до тех пор, пока максимальное количество тайлов не будет размещено в одном ряду или столбце. Например, игра для четырех игроков может быть либо шириной в четыре тайла, либо высотой в четыре тайла. Это определяется, как только четвертый тайл размещается в строке или столбце.
|
|
||||||
|
|
||||||
<!-- block: rules.board-building.tile-placement.example; page: 16; kind: example -->
|
|
||||||
#### Пример размещения тайла
|
|
||||||
|
|
||||||
Настала очередь игрока за Эльдар размещать тайл во время игры вчетвером. Он решает разместить тайл своей фракции.
|
|
||||||
|
|
||||||
Он может разместить тайл своей фракции в любом из восьми возможных мест, обведенных зеленым.
|
|
||||||
Он не может разместить свой тайл ни на одной из трех ячеек на концах, потому что это нарушит максимальный размер игрового поля, отведенный для игры вчетвером, который составляет три тайла на четыре тайла.
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
export const qk = {
|
export const qk = {
|
||||||
me: ["me"] as const,
|
me: ["me"] as const,
|
||||||
adminMe: ["adminMe"] as const,
|
adminMe: ["adminMe"] as const,
|
||||||
@@ -21,22 +19,19 @@ export const qk = {
|
|||||||
authConfig: ["authConfig"] as const,
|
authConfig: ["authConfig"] as const,
|
||||||
invitations: ["invitations"] as const,
|
invitations: ["invitations"] as const,
|
||||||
notifications: ["notifications"] as const,
|
notifications: ["notifications"] as const,
|
||||||
announcements: ["announcements"] as const,
|
|
||||||
devUsers: ["devUsers"] as const,
|
devUsers: ["devUsers"] as const,
|
||||||
adminUsers: ["adminUsers"] as const,
|
adminUsers: ["adminUsers"] as const,
|
||||||
adminGroups: ["adminGroups"] as const,
|
adminGroups: ["adminGroups"] as const,
|
||||||
adminMatches: ["adminMatches"] as const,
|
adminMatches: ["adminMatches"] as const,
|
||||||
adminLogs: ["adminLogs"] as const,
|
adminLogs: ["adminLogs"] as const,
|
||||||
adminAchievements: ["adminAchievements"] as const,
|
adminAchievements: ["adminAchievements"] as const,
|
||||||
adminAnnouncements: ["adminAnnouncements"] as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
|
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
|
||||||
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
|
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
|
||||||
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
|
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
|
||||||
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
|
* в этом файле тихо разойдётся с местами, где он написан строкой.
|
||||||
* написан строкой.
|
|
||||||
*/
|
*/
|
||||||
export const matchAffectedKeys = [
|
export const matchAffectedKeys = [
|
||||||
qk.home,
|
qk.home,
|
||||||
@@ -45,15 +40,3 @@ export const matchAffectedKeys = [
|
|||||||
["userMatches"],
|
["userMatches"],
|
||||||
["publicProfile"],
|
["publicProfile"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Все рейтинговые витрины, включая статистику любой группы: рейтинг игроков в ней
|
|
||||||
* общий, так что его двигает и партия другой группы (#88). Перезапрашиваются только
|
|
||||||
* открытые на экране запросы, остальные лишь помечаются устаревшими.
|
|
||||||
*/
|
|
||||||
export function invalidateRatingViews(qc: QueryClient) {
|
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
predicate: (q) => q.queryKey[0] === "group" && q.queryKey[2] === "stats",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+15
-775
@@ -21,46 +21,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/auth/register": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Password Register
|
|
||||||
* @description Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.
|
|
||||||
*/
|
|
||||||
post: operations["password_register_api_auth_register_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/auth/login": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Password Login
|
|
||||||
* @description Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429.
|
|
||||||
*/
|
|
||||||
post: operations["password_login_api_auth_login_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/auth/telegram": {
|
"/api/auth/telegram": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -162,46 +122,6 @@ export interface paths {
|
|||||||
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/users/me/password": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
/**
|
|
||||||
* Change My Password
|
|
||||||
* @description Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).
|
|
||||||
*/
|
|
||||||
put: operations["change_my_password_api_users_me_password_put"];
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/users/me/telegram": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Link My Telegram
|
|
||||||
* @description Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе.
|
|
||||||
*/
|
|
||||||
post: operations["link_my_telegram_api_users_me_telegram_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/users/me/avatar": {
|
"/api/users/me/avatar": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -377,11 +297,8 @@ export interface paths {
|
|||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
/**
|
/** Rename Group */
|
||||||
* Update Group
|
patch: operations["rename_group_api_groups__group_id__patch"];
|
||||||
* @description Название и домашние правила группы; меняются только переданные поля.
|
|
||||||
*/
|
|
||||||
patch: operations["update_group_api_groups__group_id__patch"];
|
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/groups/{group_id}/expansions": {
|
"/api/groups/{group_id}/expansions": {
|
||||||
@@ -842,40 +759,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/announcements/pending": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/** Pending */
|
|
||||||
get: operations["pending_api_announcements_pending_get"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/announcements/{announcement_id}/ack": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/** Acknowledge */
|
|
||||||
post: operations["acknowledge_api_announcements__announcement_id__ack_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/auth/login": {
|
"/api/admin/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -961,26 +844,6 @@ export interface paths {
|
|||||||
patch: operations["update_user_api_admin_users__user_id__patch"];
|
patch: operations["update_user_api_admin_users__user_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/admin/users/{user_id}/password": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
/**
|
|
||||||
* Set User Password
|
|
||||||
* @description Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется.
|
|
||||||
*/
|
|
||||||
put: operations["set_user_password_api_admin_users__user_id__password_put"];
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/groups": {
|
"/api/admin/groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1174,62 +1037,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/admin/announcements": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/** List Announcements */
|
|
||||||
get: operations["list_announcements_api_admin_announcements_get"];
|
|
||||||
put?: never;
|
|
||||||
/** Create Announcement */
|
|
||||||
post: operations["create_announcement_api_admin_announcements_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/announcements/{announcement_id}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
/** Update Announcement */
|
|
||||||
put: operations["update_announcement_api_admin_announcements__announcement_id__put"];
|
|
||||||
post?: never;
|
|
||||||
/** Delete Announcement */
|
|
||||||
delete: operations["delete_announcement_api_admin_announcements__announcement_id__delete"];
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/announcements/{announcement_id}/stop": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Stop Announcement
|
|
||||||
* @description «Снять с показа»: период идущего объявления заканчивается сейчас.
|
|
||||||
*/
|
|
||||||
post: operations["stop_announcement_api_admin_announcements__announcement_id__stop_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/admin/audit-logs": {
|
"/api/admin/audit-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1367,36 +1174,6 @@ export interface components {
|
|||||||
/** Group Id */
|
/** Group Id */
|
||||||
group_id?: number | null;
|
group_id?: number | null;
|
||||||
};
|
};
|
||||||
/** AdminAnnouncementRead */
|
|
||||||
AdminAnnouncementRead: {
|
|
||||||
/** Id */
|
|
||||||
id: number;
|
|
||||||
/** Title */
|
|
||||||
title: string;
|
|
||||||
/** Body Html */
|
|
||||||
body_html: string;
|
|
||||||
/** Starts At */
|
|
||||||
starts_at: string;
|
|
||||||
/** Ends At */
|
|
||||||
ends_at: string;
|
|
||||||
/** Show To New Players */
|
|
||||||
show_to_new_players: boolean;
|
|
||||||
/** Revision */
|
|
||||||
revision: number;
|
|
||||||
/**
|
|
||||||
* Status
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
status: "planned" | "live" | "finished";
|
|
||||||
/** Seen Count */
|
|
||||||
seen_count: number;
|
|
||||||
/** Audience Count */
|
|
||||||
audience_count: number;
|
|
||||||
/** Created At */
|
|
||||||
created_at: string;
|
|
||||||
/** Updated At */
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
/** AdminGroupRead */
|
/** AdminGroupRead */
|
||||||
AdminGroupRead: {
|
AdminGroupRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1430,7 +1207,7 @@ export interface components {
|
|||||||
/** Duration Minutes */
|
/** Duration Minutes */
|
||||||
duration_minutes?: number | null;
|
duration_minutes?: number | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
||||||
/** Player Count */
|
/** Player Count */
|
||||||
player_count: number;
|
player_count: number;
|
||||||
/** Created By */
|
/** Created By */
|
||||||
@@ -1447,11 +1224,6 @@ export interface components {
|
|||||||
/** Role */
|
/** Role */
|
||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
/** AdminPasswordSet */
|
|
||||||
AdminPasswordSet: {
|
|
||||||
/** New Password */
|
|
||||||
new_password: string;
|
|
||||||
};
|
|
||||||
/** AdminUserRead */
|
/** AdminUserRead */
|
||||||
AdminUserRead: {
|
AdminUserRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1468,11 +1240,6 @@ export interface components {
|
|||||||
telegram_id?: number | null;
|
telegram_id?: number | null;
|
||||||
/** Created At */
|
/** Created At */
|
||||||
created_at: string;
|
created_at: string;
|
||||||
/**
|
|
||||||
* Has Password
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
has_password: boolean;
|
|
||||||
};
|
};
|
||||||
/** AdminUserUpdate */
|
/** AdminUserUpdate */
|
||||||
AdminUserUpdate: {
|
AdminUserUpdate: {
|
||||||
@@ -1481,76 +1248,6 @@ export interface components {
|
|||||||
/** Is Active */
|
/** Is Active */
|
||||||
is_active?: boolean | null;
|
is_active?: boolean | null;
|
||||||
};
|
};
|
||||||
/** AnnouncementAck */
|
|
||||||
AnnouncementAck: {
|
|
||||||
/** Revision */
|
|
||||||
revision: number;
|
|
||||||
};
|
|
||||||
/** AnnouncementRead */
|
|
||||||
AnnouncementRead: {
|
|
||||||
/** Id */
|
|
||||||
id: number;
|
|
||||||
/** Title */
|
|
||||||
title: string;
|
|
||||||
/** Body Html */
|
|
||||||
body_html: string;
|
|
||||||
/** Revision */
|
|
||||||
revision: number;
|
|
||||||
/**
|
|
||||||
* Updated
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
updated: boolean;
|
|
||||||
};
|
|
||||||
/** AnnouncementUpdate */
|
|
||||||
AnnouncementUpdate: {
|
|
||||||
/** Title */
|
|
||||||
title: string;
|
|
||||||
/** Body Html */
|
|
||||||
body_html: string;
|
|
||||||
/**
|
|
||||||
* Starts At
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
starts_at: string;
|
|
||||||
/**
|
|
||||||
* Ends At
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
ends_at: string;
|
|
||||||
/**
|
|
||||||
* Show To New Players
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
show_to_new_players: boolean;
|
|
||||||
/**
|
|
||||||
* Reshow
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
reshow: boolean;
|
|
||||||
};
|
|
||||||
/** AnnouncementWrite */
|
|
||||||
AnnouncementWrite: {
|
|
||||||
/** Title */
|
|
||||||
title: string;
|
|
||||||
/** Body Html */
|
|
||||||
body_html: string;
|
|
||||||
/**
|
|
||||||
* Starts At
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
starts_at: string;
|
|
||||||
/**
|
|
||||||
* Ends At
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
ends_at: string;
|
|
||||||
/**
|
|
||||||
* Show To New Players
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
show_to_new_players: boolean;
|
|
||||||
};
|
|
||||||
/** AttachmentRead */
|
/** AttachmentRead */
|
||||||
AttachmentRead: {
|
AttachmentRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1769,11 +1466,6 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
expansion_ids: number[];
|
expansion_ids: number[];
|
||||||
/**
|
|
||||||
* Nine Rounds Rule
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
nine_rounds_rule: boolean;
|
|
||||||
};
|
};
|
||||||
/** GroupExpansionsUpdate */
|
/** GroupExpansionsUpdate */
|
||||||
GroupExpansionsUpdate: {
|
GroupExpansionsUpdate: {
|
||||||
@@ -1783,6 +1475,11 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
expansion_ids: number[];
|
expansion_ids: number[];
|
||||||
};
|
};
|
||||||
|
/** GroupRename */
|
||||||
|
GroupRename: {
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
/** GroupStats */
|
/** GroupStats */
|
||||||
GroupStats: {
|
GroupStats: {
|
||||||
/** Group Id */
|
/** Group Id */
|
||||||
@@ -1814,13 +1511,6 @@ export interface components {
|
|||||||
/** Min Games */
|
/** Min Games */
|
||||||
min_games: number;
|
min_games: number;
|
||||||
};
|
};
|
||||||
/** GroupUpdate */
|
|
||||||
GroupUpdate: {
|
|
||||||
/** Name */
|
|
||||||
name?: string | null;
|
|
||||||
/** Nine Rounds Rule */
|
|
||||||
nine_rounds_rule?: boolean | null;
|
|
||||||
};
|
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
@@ -1914,11 +1604,6 @@ export interface components {
|
|||||||
rank?: number | null;
|
rank?: number | null;
|
||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
/**
|
|
||||||
* Rating Confirmed
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
rating_confirmed: boolean;
|
|
||||||
};
|
};
|
||||||
/** MatchCreate */
|
/** MatchCreate */
|
||||||
MatchCreate: {
|
MatchCreate: {
|
||||||
@@ -1935,9 +1620,7 @@ export interface components {
|
|||||||
* Win Reason
|
* Win Reason
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing";
|
win_reason: "objectives" | "worlds" | "plastic" | "resources";
|
||||||
/** End Round */
|
|
||||||
end_round?: number | null;
|
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
overall_comment?: string | null;
|
overall_comment?: string | null;
|
||||||
/** Expected Version */
|
/** Expected Version */
|
||||||
@@ -1968,25 +1651,9 @@ export interface components {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
overall_comment?: string | null;
|
overall_comment?: string | null;
|
||||||
/** End Round */
|
|
||||||
end_round?: number | null;
|
|
||||||
/**
|
|
||||||
* Objectives
|
|
||||||
* @default {}
|
|
||||||
*/
|
|
||||||
objectives: {
|
|
||||||
[key: string]: number;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Worlds
|
|
||||||
* @default {}
|
|
||||||
*/
|
|
||||||
worlds: {
|
|
||||||
[key: string]: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
/** MatchFinishDraftRead */
|
/** MatchFinishDraftRead */
|
||||||
MatchFinishDraftRead: {
|
MatchFinishDraftRead: {
|
||||||
@@ -2013,10 +1680,6 @@ export interface components {
|
|||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** Faction Id */
|
/** Faction Id */
|
||||||
faction_id?: number | null;
|
faction_id?: number | null;
|
||||||
/** Objectives */
|
|
||||||
objectives?: number | null;
|
|
||||||
/** Worlds */
|
|
||||||
worlds?: number | null;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* MatchHistory
|
* MatchHistory
|
||||||
@@ -2071,7 +1734,7 @@ export interface components {
|
|||||||
/** Duration Minutes */
|
/** Duration Minutes */
|
||||||
duration_minutes?: number | null;
|
duration_minutes?: number | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
||||||
/** Player Count */
|
/** Player Count */
|
||||||
player_count: number;
|
player_count: number;
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
@@ -2083,8 +1746,6 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
participants: components["schemas"]["MatchListParticipant"][];
|
participants: components["schemas"]["MatchListParticipant"][];
|
||||||
/** Rating Delta */
|
|
||||||
rating_delta?: number | null;
|
|
||||||
};
|
};
|
||||||
/** MatchListParticipant */
|
/** MatchListParticipant */
|
||||||
MatchListParticipant: {
|
MatchListParticipant: {
|
||||||
@@ -2107,10 +1768,6 @@ export interface components {
|
|||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** Objectives */
|
|
||||||
objectives?: number | null;
|
|
||||||
/** Worlds */
|
|
||||||
worlds?: number | null;
|
|
||||||
};
|
};
|
||||||
/** MatchParticipantRead */
|
/** MatchParticipantRead */
|
||||||
MatchParticipantRead: {
|
MatchParticipantRead: {
|
||||||
@@ -2133,10 +1790,6 @@ export interface components {
|
|||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** Objectives */
|
|
||||||
objectives?: number | null;
|
|
||||||
/** Worlds */
|
|
||||||
worlds?: number | null;
|
|
||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
};
|
};
|
||||||
@@ -2160,16 +1813,7 @@ export interface components {
|
|||||||
/** Duration Minutes */
|
/** Duration Minutes */
|
||||||
duration_minutes?: number | null;
|
duration_minutes?: number | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
||||||
/** End Round */
|
|
||||||
end_round?: number | null;
|
|
||||||
/**
|
|
||||||
* Nine Rounds Rule
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
nine_rounds_rule: boolean;
|
|
||||||
/** Max Rounds */
|
|
||||||
max_rounds: number;
|
|
||||||
/** Player Count */
|
/** Player Count */
|
||||||
player_count: number;
|
player_count: number;
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
@@ -2202,9 +1846,7 @@ export interface components {
|
|||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
overall_comment?: string | null;
|
overall_comment?: string | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
||||||
/** End Round */
|
|
||||||
end_round?: number | null;
|
|
||||||
/** Participants */
|
/** Participants */
|
||||||
participants?: components["schemas"]["ParticipantInput"][] | null;
|
participants?: components["schemas"]["ParticipantInput"][] | null;
|
||||||
/** Expected Version */
|
/** Expected Version */
|
||||||
@@ -2245,11 +1887,6 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
groups: components["schemas"]["GroupBrief"][];
|
groups: components["schemas"]["GroupBrief"][];
|
||||||
/**
|
|
||||||
* Has Password
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
has_password: boolean;
|
|
||||||
};
|
};
|
||||||
/** MemberAdd */
|
/** MemberAdd */
|
||||||
MemberAdd: {
|
MemberAdd: {
|
||||||
@@ -2352,31 +1989,6 @@ export interface components {
|
|||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** Objectives */
|
|
||||||
objectives?: number | null;
|
|
||||||
/** Worlds */
|
|
||||||
worlds?: number | null;
|
|
||||||
};
|
|
||||||
/** PasswordChange */
|
|
||||||
PasswordChange: {
|
|
||||||
/** Current Password */
|
|
||||||
current_password?: string | null;
|
|
||||||
/** New Password */
|
|
||||||
new_password: string;
|
|
||||||
};
|
|
||||||
/** PasswordLogin */
|
|
||||||
PasswordLogin: {
|
|
||||||
/** Nickname */
|
|
||||||
nickname: string;
|
|
||||||
/** Password */
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
/** PasswordRegister */
|
|
||||||
PasswordRegister: {
|
|
||||||
/** Nickname */
|
|
||||||
nickname: string;
|
|
||||||
/** Password */
|
|
||||||
password: string;
|
|
||||||
};
|
};
|
||||||
/** ProfileStats */
|
/** ProfileStats */
|
||||||
ProfileStats: {
|
ProfileStats: {
|
||||||
@@ -2586,72 +2198,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
password_register_api_auth_register_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["PasswordRegister"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["MeRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
password_login_api_auth_login_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["PasswordLogin"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["MeRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
telegram_login_api_auth_telegram_post: {
|
telegram_login_api_auth_telegram_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2824,72 +2370,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
change_my_password_api_users_me_password_put: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["PasswordChange"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["MeRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
link_my_telegram_api_users_me_telegram_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["TelegramAuthPayload"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["MeRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
upload_my_avatar_api_users_me_avatar_put: {
|
upload_my_avatar_api_users_me_avatar_put: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3239,7 +2719,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
update_group_api_groups__group_id__patch: {
|
rename_group_api_groups__group_id__patch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -3250,7 +2730,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["GroupUpdate"];
|
"application/json": components["schemas"]["GroupRename"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
@@ -4170,61 +3650,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
pending_api_announcements_pending_get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AnnouncementRead"][];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
acknowledge_api_announcements__announcement_id__ack_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
announcement_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AnnouncementAck"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["OkResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
admin_login_api_admin_auth_login_post: {
|
admin_login_api_admin_auth_login_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4364,41 +3789,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
set_user_password_api_admin_users__user_id__password_put: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
user_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AdminPasswordSet"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AdminUserRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
list_groups_api_admin_groups_get: {
|
list_groups_api_admin_groups_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4921,156 +4311,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
list_announcements_api_admin_announcements_get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AdminAnnouncementRead"][];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
create_announcement_api_admin_announcements_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AnnouncementWrite"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AdminAnnouncementRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
update_announcement_api_admin_announcements__announcement_id__put: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
announcement_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AnnouncementUpdate"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AdminAnnouncementRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
delete_announcement_api_admin_announcements__announcement_id__delete: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
announcement_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["OkResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
stop_announcement_api_admin_announcements__announcement_id__stop_post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
announcement_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["AdminAnnouncementRead"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
audit_logs_api_admin_audit_logs_get: {
|
audit_logs_api_admin_audit_logs_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { OverallStatsPage } from "../pages/OverallStatsPage";
|
|||||||
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
||||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||||
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
|
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
|
||||||
import { AdminAnnouncementsPage } from "../pages/admin/AdminAnnouncementsPage";
|
|
||||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||||
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
||||||
@@ -71,7 +70,6 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "factions", element: <AdminFactionsPage /> },
|
{ path: "factions", element: <AdminFactionsPage /> },
|
||||||
{ path: "achievements", element: <AdminAchievementsPage /> },
|
{ path: "achievements", element: <AdminAchievementsPage /> },
|
||||||
{ path: "logs", element: <AdminLogsPage /> },
|
{ path: "logs", element: <AdminLogsPage /> },
|
||||||
{ path: "announcements", element: <AdminAnnouncementsPage /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "*", element: <Navigate to="/" replace /> },
|
{ path: "*", element: <Navigate to="/" replace /> },
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { useAckAnnouncement, usePendingAnnouncements } from "../hooks/announcements";
|
|
||||||
import { AnnouncementWindow } from "./AnnouncementWindow";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Очередь объявлений администрации поверх любой страницы: по одному, от старого
|
|
||||||
* к новому, следующее — после «Понятно» на предыдущем. AppShell монтирует её только
|
|
||||||
* когда пароль задан: обязательное окно пароля всегда первое.
|
|
||||||
*/
|
|
||||||
export function AnnouncementDialog() {
|
|
||||||
const { data } = usePendingAnnouncements(true);
|
|
||||||
const ack = useAckAnnouncement();
|
|
||||||
// Сколько уже закрыто в этой очереди — для точек «2 из 3». Закрытые сразу уходят
|
|
||||||
// из данных запроса, так что без счётчика точки показывали бы только остаток.
|
|
||||||
const [closed, setClosed] = useState(0);
|
|
||||||
const items = data ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (items.length === 0) setClosed(0);
|
|
||||||
}, [items.length]);
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
const current = items[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnnouncementWindow
|
|
||||||
key={current.id}
|
|
||||||
title={current.title}
|
|
||||||
bodyHtml={current.body_html}
|
|
||||||
updated={current.updated}
|
|
||||||
index={closed}
|
|
||||||
total={closed + items.length}
|
|
||||||
onOk={() => {
|
|
||||||
setClosed((c) => c + 1);
|
|
||||||
ack.mutate({ id: current.id, revision: current.revision });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useId } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Окно объявления администрации (вариант E из #84): заголовок — в плашке-шапке, ниже текст,
|
|
||||||
* точки очереди и «Понятно». Крестика нет намеренно: закрытие — только осознанное.
|
|
||||||
*
|
|
||||||
* bodyHtml у игрока — HTML, очищенный сервером по белому списку (announcement_service),
|
|
||||||
* поэтому вставляется как есть. В предпросмотре админки — содержимое его же редактора.
|
|
||||||
*/
|
|
||||||
export function AnnouncementWindow({
|
|
||||||
title,
|
|
||||||
bodyHtml,
|
|
||||||
updated = false,
|
|
||||||
index = 0,
|
|
||||||
total = 1,
|
|
||||||
onOk,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
bodyHtml: string;
|
|
||||||
updated?: boolean;
|
|
||||||
index?: number;
|
|
||||||
total?: number;
|
|
||||||
onOk: () => void;
|
|
||||||
}) {
|
|
||||||
const titleId = useId();
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal ann-modal" role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
|
||||||
<div className="ann-ribbon">
|
|
||||||
<div className="ann-ribbon-title" id={titleId}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{updated && <div className="ann-updated">обновлено</div>}
|
|
||||||
</div>
|
|
||||||
<div className="ann-body" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
|
||||||
<div className="modal-actions ann-actions">
|
|
||||||
<div className="ann-dots" aria-hidden="true">
|
|
||||||
{total > 1 &&
|
|
||||||
Array.from({ length: total }, (_, k) => (
|
|
||||||
<i key={k} className={k <= index ? "on" : undefined} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary" onClick={onOk} autoFocus>
|
|
||||||
Понятно
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,9 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useServerEvents } from "../hooks/useServerEvents";
|
import { useServerEvents } from "../hooks/useServerEvents";
|
||||||
import { AnnouncementDialog } from "./AnnouncementDialog";
|
|
||||||
import { BottomBar } from "./BottomBar";
|
import { BottomBar } from "./BottomBar";
|
||||||
import { NotificationBell } from "./NotificationBell";
|
import { NotificationBell } from "./NotificationBell";
|
||||||
import { NotificationToaster } from "./NotificationToaster";
|
import { NotificationToaster } from "./NotificationToaster";
|
||||||
import { SetPasswordDialog } from "./SetPasswordDialog";
|
|
||||||
import { SideMenu } from "./SideMenu";
|
import { SideMenu } from "./SideMenu";
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
@@ -53,11 +51,6 @@ export function AppShell() {
|
|||||||
|
|
||||||
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||||
<BottomBar onMenu={() => setMenuOpen(true)} />
|
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||||
|
|
||||||
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
|
|
||||||
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
|
|
||||||
{/* Объявления администрации — только после окна пароля, на любой странице. */}
|
|
||||||
{me?.has_password && <AnnouncementDialog />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useCreateGroup } from "../hooks/groups";
|
import { useCreateGroup } from "../hooks/groups";
|
||||||
import { useExpansions } from "../hooks/reference";
|
import { useExpansions } from "../hooks/reference";
|
||||||
import { Switch } from "./Switch";
|
import { Switch } from "./Switch";
|
||||||
@@ -8,6 +9,7 @@ import { Switch } from "./Switch";
|
|||||||
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||||
const { data: expansions } = useExpansions();
|
const { data: expansions } = useExpansions();
|
||||||
const createGroup = useCreateGroup();
|
const createGroup = useCreateGroup();
|
||||||
|
const toast = useToast();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -23,6 +25,7 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
||||||
|
toast.show("Группа создана");
|
||||||
onCreated?.();
|
onCreated?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
||||||
|
|||||||
@@ -15,15 +15,16 @@ export function GroupInvitations() {
|
|||||||
await accept.mutateAsync(id);
|
await accept.mutateAsync(id);
|
||||||
toast.show("Вы вступили в группу");
|
toast.show("Вы вступили в группу");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDecline = async (id: number) => {
|
const onDecline = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await decline.mutateAsync(id);
|
await decline.mutateAsync(id);
|
||||||
|
toast.show("Приглашение отклонено");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,8 @@ function Row({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только
|
{/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только
|
||||||
подтверждённый рейтинг. Рейтинг общий, поэтому подтверждённость приходит
|
подтверждённый рейтинг. */}
|
||||||
флагом: в группе «Ещё не играли» бывают и новички, и опытные игроки. */}
|
<div className={"lb-score" + (provisional || entry.score == null ? " provisional" : "")}>
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"lb-score" +
|
|
||||||
(provisional || entry.score == null || !entry.rating_confirmed ? " provisional" : "")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{entry.score ?? "—"}
|
{entry.score ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatDate, formatDuration } from "../domain/format";
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
import { MatchListView } from "./MatchList";
|
import { MatchListView } from "./MatchList";
|
||||||
import { RatingDelta } from "./RatingDelta";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
||||||
@@ -54,33 +53,29 @@ export function MatchHistory({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="card"
|
className="card row-between"
|
||||||
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||||
onClick={() => navigate(`/match/${m.id}`)}
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
>
|
>
|
||||||
<div className="row-between">
|
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
<span
|
||||||
<span
|
className={
|
||||||
className={
|
"place-badge" +
|
||||||
"place-badge" +
|
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
||||||
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
(mine?.eliminated ? " eliminated" : "")
|
||||||
(mine?.eliminated ? " eliminated" : "")
|
}
|
||||||
}
|
>
|
||||||
>
|
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
||||||
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
</span>
|
||||||
</span>
|
<span
|
||||||
<span
|
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
>
|
||||||
>
|
{mine?.faction_name ?? "—"}
|
||||||
{mine?.faction_name ?? "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="muted small" style={{ flexShrink: 0 }}>
|
|
||||||
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
|
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||||
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatDate, formatDuration } from "../domain/format";
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
import { winReasonLabel } from "../domain/winReasons";
|
import { winReasonLabel } from "../domain/winReasons";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
import { RatingDelta } from "./RatingDelta";
|
|
||||||
|
|
||||||
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
||||||
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
||||||
* отфильтрованной по status="finished". В истории профиля у партии есть rating_delta
|
* отфильтрованной по status="finished". */
|
||||||
* владельца — она идёт последней строкой; у списка группы её нет. */
|
|
||||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||||
@@ -50,7 +48,6 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ export function MatchMedia({
|
|||||||
let slots = max - attachments.length;
|
let slots = max - attachments.length;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (slots <= 0) {
|
if (slots <= 0) {
|
||||||
toast.error(`Максимум ${max} фото`);
|
toast.show(`Максимум ${max} фото`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await onUpload(f);
|
await onUpload(f);
|
||||||
slots--;
|
slots--;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export function MatchMedia({
|
|||||||
try {
|
try {
|
||||||
await onDelete(id);
|
await onDelete(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useNotifications, type Notification } from "../hooks/notifications";
|
||||||
import { useNotifications } from "../hooks/notifications";
|
|
||||||
|
const TOAST_TTL_MS = 5000;
|
||||||
|
const MAX_VISIBLE = 4;
|
||||||
|
|
||||||
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
||||||
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id.
|
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */
|
||||||
* Показывает их общая стопка ToastProvider — там же, где сообщения приложения. */
|
|
||||||
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
||||||
const { data } = useNotifications(enabled);
|
const { data } = useNotifications(enabled);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
|
||||||
const seen = useRef<Set<number> | null>(null);
|
const seen = useRef<Set<number> | null>(null);
|
||||||
|
const [toasts, setToasts] = useState<Notification[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -21,16 +22,29 @@ export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
||||||
|
if (fresh.length === 0) return;
|
||||||
fresh.forEach((n) => seen.current!.add(n.id));
|
fresh.forEach((n) => seen.current!.add(n.id));
|
||||||
// Стопка добавляет новое наверх, поэтому идём с конца: первое в списке окажется сверху.
|
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE));
|
||||||
[...fresh].reverse().forEach((n) =>
|
fresh.forEach((n) => {
|
||||||
toast.notify({
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS);
|
||||||
title: n.title,
|
});
|
||||||
body: n.body,
|
}, [data]);
|
||||||
onClick: n.link ? () => navigate(n.link!) : undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [data, toast, navigate]);
|
|
||||||
|
|
||||||
return null;
|
if (!enabled || toasts.length === 0) return null;
|
||||||
|
|
||||||
|
const click = (n: Notification) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== n.id));
|
||||||
|
if (n.link) navigate(n.link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast-top-wrap">
|
||||||
|
{toasts.map((n) => (
|
||||||
|
<button key={n.id} className="toast-top" onClick={() => click(n)}>
|
||||||
|
<div className="toast-top-title">{n.title}</div>
|
||||||
|
{n.body && <div className="toast-top-body">{n.body}</div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Scissors } from "lucide-react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
|
||||||
|
|
||||||
export interface PlacePlayer {
|
export interface PlacePlayer {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -11,10 +10,6 @@ export interface PlacePlayer {
|
|||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CountField = "objectives" | "worlds";
|
|
||||||
/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */
|
|
||||||
export type Counts = Record<number, { objectives: number | null; worlds: number | null }>;
|
|
||||||
|
|
||||||
type Target =
|
type Target =
|
||||||
| { type: "merge"; idx: number }
|
| { type: "merge"; idx: number }
|
||||||
| { type: "insert"; idx: number }
|
| { type: "insert"; idx: number }
|
||||||
@@ -33,8 +28,7 @@ interface DragState {
|
|||||||
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
|
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
|
||||||
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
|
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
|
||||||
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
|
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
|
||||||
* выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
|
* выбывший. В каждом блоке — строка комментария об игроке.
|
||||||
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
|
|
||||||
*
|
*
|
||||||
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
|
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
|
||||||
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
|
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
|
||||||
@@ -47,8 +41,6 @@ export function PlaceEditor({
|
|||||||
comments,
|
comments,
|
||||||
onChange,
|
onChange,
|
||||||
onComment,
|
onComment,
|
||||||
counts,
|
|
||||||
onCount,
|
|
||||||
}: {
|
}: {
|
||||||
players: PlacePlayer[];
|
players: PlacePlayer[];
|
||||||
blocks: number[][];
|
blocks: number[][];
|
||||||
@@ -56,8 +48,6 @@ export function PlaceEditor({
|
|||||||
comments: Record<number, string>;
|
comments: Record<number, string>;
|
||||||
onChange: (blocks: number[][], eliminated: number[]) => void;
|
onChange: (blocks: number[][], eliminated: number[]) => void;
|
||||||
onComment: (userId: number, text: string) => void;
|
onComment: (userId: number, text: string) => void;
|
||||||
counts?: Counts;
|
|
||||||
onCount?: (userId: number, field: CountField, value: number | null) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const elimRef = useRef<HTMLDivElement>(null);
|
const elimRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -235,41 +225,15 @@ export function PlaceEditor({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const countInput = (userId: number, field: CountField, label: string, locked = false) => (
|
const commentInput = (userId: number) => (
|
||||||
<label className="rank-count">
|
<input
|
||||||
<span>{label}</span>
|
className="rank-comment"
|
||||||
<input
|
placeholder="Комментарий об игроке"
|
||||||
type="number"
|
value={comments[userId] ?? ""}
|
||||||
inputMode="numeric"
|
onChange={(e) => onComment(userId, e.target.value)}
|
||||||
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="rank-list" ref={listRef}>
|
<div className="rank-list" ref={listRef}>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function ProfileStatsCard({
|
|||||||
<b>{o.avg_place ?? "—"}</b>
|
<b>{o.avg_place ?? "—"}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>Рейтинг</div>
|
<div>Очки (рейтинг)</div>
|
||||||
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
|
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
|
||||||
</div>
|
</div>
|
||||||
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
|
||||||
import { useLogout, useSetPassword } from "../hooks/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обязательная установка пароля — для аккаунтов из Telegram и созданных до появления
|
|
||||||
* паролей. Закрыть окно нельзя: только задать пароль или выйти. Кнопка «Позже» есть
|
|
||||||
* лишь в dev-сборке (import.meta.env.DEV) — чтобы тестовые stub-игроки не упирались в окно.
|
|
||||||
*/
|
|
||||||
export function SetPasswordDialog({ nickname }: { nickname: string }) {
|
|
||||||
const setPassword = useSetPassword();
|
|
||||||
const logout = useLogout();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [password, setPasswordValue] = useState("");
|
|
||||||
const [repeat, setRepeat] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [skipped, setSkipped] = useState(false);
|
|
||||||
|
|
||||||
if (skipped) return null;
|
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (password !== repeat) {
|
|
||||||
setError("Пароли не совпадают.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
// Успех кладёт в кэш профиль с has_password=true — окно исчезает само.
|
|
||||||
await setPassword.mutateAsync({ new_password: password });
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doLogout = async () => {
|
|
||||||
await logout.mutateAsync().catch(() => {});
|
|
||||||
navigate("/login", { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<form className="modal" onSubmit={submit}>
|
|
||||||
<div className="modal-title">Задайте пароль</div>
|
|
||||||
<p className="modal-msg">
|
|
||||||
Теперь на сайт входят по логину и паролю. Ваш логин — никнейм <b>{nickname}</b>.
|
|
||||||
Придумайте пароль, чтобы продолжить; вход через Telegram тоже останется.
|
|
||||||
</p>
|
|
||||||
{/* Скрытый логин — чтобы менеджер паролей сохранил пару целиком. */}
|
|
||||||
<input type="text" name="username" autoComplete="username" value={nickname} readOnly hidden />
|
|
||||||
<div className="field">
|
|
||||||
<label className="label" htmlFor="set-password">
|
|
||||||
Пароль (не короче 8 символов)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="set-password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
autoFocus
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPasswordValue(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="label" htmlFor="set-password-repeat">
|
|
||||||
Повторите пароль
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="set-password-repeat"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={repeat}
|
|
||||||
onChange={(e) => setRepeat(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="error-text">{error}</p>}
|
|
||||||
<div className="modal-actions">
|
|
||||||
{import.meta.env.DEV && (
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setSkipped(true)}>
|
|
||||||
Позже (dev)
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={doLogout}>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={setPassword.isPending || !password}>
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +1,31 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
// Сообщения приложения и входящие уведомления — одна стопка всплывашек сверху экрана.
|
|
||||||
type ToastKind = "info" | "error" | "notification";
|
|
||||||
|
|
||||||
interface ToastItem {
|
|
||||||
id: number;
|
|
||||||
kind: ToastKind;
|
|
||||||
title: string;
|
|
||||||
body?: string | null;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastCtx {
|
interface ToastCtx {
|
||||||
/** Нейтральное сообщение: действие прошло, но на экране это не видно. */
|
|
||||||
show: (message: string) => void;
|
show: (message: string) => void;
|
||||||
/** Ошибка или отказ — красная полоса, держится дольше. */
|
|
||||||
error: (message: string) => void;
|
|
||||||
/** Входящее уведомление (заголовок, текст, переход по клику). */
|
|
||||||
notify: (n: { title: string; body?: string | null; onClick?: () => void }) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTL_MS: Record<ToastKind, number> = { info: 3000, error: 5000, notification: 5000 };
|
const Ctx = createContext<ToastCtx>({ show: () => {} });
|
||||||
const MAX_VISIBLE = 4;
|
|
||||||
|
|
||||||
const Ctx = createContext<ToastCtx>({ show: () => {}, error: () => {}, notify: () => {} });
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: PropsWithChildren) {
|
export function ToastProvider({ children }: PropsWithChildren) {
|
||||||
const [items, setItems] = useState<ToastItem[]>([]);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const nextId = useRef(1);
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
|
||||||
|
|
||||||
useEffect(() => {
|
const show = useCallback((msg: string) => {
|
||||||
const pending = timers.current;
|
setMessage(msg);
|
||||||
return () => pending.forEach(clearTimeout);
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
timer.current = setTimeout(() => setMessage(null), 2800);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dismiss = useCallback((id: number) => {
|
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
|
||||||
clearTimeout(timers.current.get(id));
|
|
||||||
timers.current.delete(id);
|
|
||||||
setItems((prev) => prev.filter((t) => t.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const push = useCallback(
|
|
||||||
(item: Omit<ToastItem, "id">) => {
|
|
||||||
const id = nextId.current++;
|
|
||||||
setItems((prev) => {
|
|
||||||
// Тот же текст уже на экране (двойной клик, повтор ошибки) — заменяем, а не копим.
|
|
||||||
// Уведомления не схлопываем: одинаковый заголовок бывает у разных событий.
|
|
||||||
const rest =
|
|
||||||
item.kind === "notification"
|
|
||||||
? prev
|
|
||||||
: prev.filter((t) => !(t.kind === item.kind && t.title === item.title));
|
|
||||||
return [{ ...item, id }, ...rest].slice(0, MAX_VISIBLE);
|
|
||||||
});
|
|
||||||
timers.current.set(id, setTimeout(() => dismiss(id), TTL_MS[item.kind]));
|
|
||||||
},
|
|
||||||
[dismiss],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждую всплывашку:
|
|
||||||
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
|
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
|
||||||
// потребителей, к самим всплывашкам отношения не имеющих.
|
// потребителей, к самому тосту отношения не имеющих.
|
||||||
const value = useMemo<ToastCtx>(
|
const value = useMemo(() => ({ show }), [show]);
|
||||||
() => ({
|
|
||||||
show: (message) => push({ kind: "info", title: message }),
|
|
||||||
error: (message) => push({ kind: "error", title: message }),
|
|
||||||
notify: ({ title, body, onClick }) => push({ kind: "notification", title, body, onClick }),
|
|
||||||
}),
|
|
||||||
[push],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={value}>
|
<Ctx.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
{/* Контейнер есть всегда: aria-live объявляет только то, что появилось внутри уже
|
{message && <div className="toast">{message}</div>}
|
||||||
существующей области. */}
|
|
||||||
<div className="toast-top-wrap" aria-live="polite">
|
|
||||||
{items.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
type="button"
|
|
||||||
className={"toast-top" + (t.kind === "error" ? " toast-top-error" : "")}
|
|
||||||
onClick={() => {
|
|
||||||
dismiss(t.id);
|
|
||||||
t.onClick?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="toast-top-title">{t.title}</div>
|
|
||||||
{t.body && <div className="toast-top-body">{t.body}</div>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Ctx.Provider>
|
</Ctx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,6 @@ export function plural(n: number, one: string, few: string, many: string): strin
|
|||||||
return many;
|
return many;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Изменение рейтинга за партию: всегда со знаком и одним знаком после запятой
|
|
||||||
// («+12.3», «−4.1», «0.0»); минус — типографский, как в справке.
|
|
||||||
export function formatRatingDelta(delta: number): string {
|
|
||||||
const abs = Math.abs(delta).toFixed(1);
|
|
||||||
if (abs === "0.0") return "0.0";
|
|
||||||
return `${delta > 0 ? "+" : "−"}${abs}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDuration(minutes: number | null | undefined): string {
|
export function formatDuration(minutes: number | null | undefined): string {
|
||||||
if (minutes == null) return "—";
|
if (minutes == null) return "—";
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
@@ -50,21 +42,6 @@ export function formatDate(iso: string | null | undefined): string {
|
|||||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё
|
|
||||||
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
|
|
||||||
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 — поле пустое.
|
|
||||||
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;
|
|
||||||
return new Date(ms).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(iso: string | null | undefined): string {
|
export function formatDateTime(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = shifted(iso);
|
const d = shifted(iso);
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме 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)));
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,3 @@ export type MatchUpdate = S["MatchUpdate"];
|
|||||||
export type MatchCreate = S["MatchCreate"];
|
export type MatchCreate = S["MatchCreate"];
|
||||||
export type MatchFinish = S["MatchFinish"];
|
export type MatchFinish = S["MatchFinish"];
|
||||||
export type MatchFinishDraftData = S["MatchFinishDraftData"];
|
export type MatchFinishDraftData = S["MatchFinishDraftData"];
|
||||||
export type AnnouncementRead = S["AnnouncementRead"];
|
|
||||||
export type AdminAnnouncementRead = S["AdminAnnouncementRead"];
|
|
||||||
export type AnnouncementWrite = S["AnnouncementWrite"];
|
|
||||||
export type AnnouncementUpdate = S["AnnouncementUpdate"];
|
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import type { components } from "../api/schema";
|
|||||||
|
|
||||||
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
|
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
|
||||||
|
|
||||||
export const LAST_STANDING: WinReason = "last_standing";
|
// Фиксированный порядок (сверху вниз).
|
||||||
|
|
||||||
// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший»
|
|
||||||
// сюда не входит: она ставится сама, когда невыбывший игрок остался один.
|
|
||||||
export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
||||||
{ code: "objectives", label: "По целям" },
|
{ code: "objectives", label: "По целям" },
|
||||||
{ code: "worlds", label: "По мирам" },
|
{ code: "worlds", label: "По мирам" },
|
||||||
@@ -13,21 +10,10 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
|||||||
{ code: "resources", label: "По ресурсам" },
|
{ code: "resources", label: "По ресурсам" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = Object.fromEntries(
|
||||||
...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])),
|
WIN_REASONS.map((w) => [w.code, w.label]),
|
||||||
[LAST_STANDING]: "Последний выживший",
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export function winReasonLabel(code: string | null | undefined): string {
|
export function winReasonLabel(code: string | null | undefined): string {
|
||||||
return code ? LABELS[code] ?? code : "—";
|
return code ? LABELS[code] ?? code : "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно
|
|
||||||
* один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда
|
|
||||||
* выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать.
|
|
||||||
*/
|
|
||||||
export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null {
|
|
||||||
if (survivors === 1) return LAST_STANDING;
|
|
||||||
return current === LAST_STANDING ? null : current;
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user