Compare commits
12
Commits
dev
..
a3984638c3
| 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/
|
||||||
|
|||||||
+28
-63
@@ -1,12 +1,13 @@
|
|||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# 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.
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
|
# 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.
|
||||||
@@ -44,31 +42,30 @@ ADMIN_NICKNAME=Администратор
|
|||||||
ADMIN_BOOTSTRAP_ENABLED=true
|
ADMIN_BOOTSTRAP_ENABLED=true
|
||||||
|
|
||||||
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
||||||
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
|
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
||||||
# Для 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
|
||||||
|
|
||||||
@@ -81,7 +78,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
|
|||||||
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
||||||
|
|
||||||
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
||||||
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
|
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр,
|
||||||
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
||||||
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
||||||
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
||||||
@@ -89,55 +86,23 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
||||||
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
||||||
# Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён
|
# Подними, если у Pi больше RAM/ядер.
|
||||||
# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1.
|
|
||||||
#APP_MEM_LIMIT=512m
|
#APP_MEM_LIMIT=512m
|
||||||
#APP_CPUS=1.5
|
#APP_CPUS=1.5
|
||||||
|
|
||||||
# ─── БЭКАПЫ (контейнер 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=
|
|
||||||
# Отчёты о бэкапах в Telegram и команды /backups, /status (deploy/backup/README.md, раздел 10).
|
|
||||||
# id своего чата: напишите боту /start → `docker compose exec backup fs-backup telegram chats`. Несколько — через
|
|
||||||
# запятую. Пусто = отчёты выключены. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения.
|
|
||||||
BACKUP_TELEGRAM_CHAT_ID=
|
|
||||||
#BACKUP_TELEGRAM_BOT_TOKEN=
|
|
||||||
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
|
||||||
# docker-compose.yml прода.
|
|
||||||
BACKUP_PI_SSH=pi@192.168.1.10
|
|
||||||
BACKUP_PI_DIR=~/forbidden-stars
|
|
||||||
|
|
||||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||||
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
|
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
||||||
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
|
|
||||||
# пояс устройства игрока не учитывается.
|
|
||||||
APP_TZ_OFFSET_HOURS=3
|
APP_TZ_OFFSET_HOURS=3
|
||||||
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|||||||
+16
-1
@@ -1,5 +1,20 @@
|
|||||||
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
||||||
# Шелл-скрипты обязаны быть с 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` (чистая выгрузка прод/тест) ─────
|
||||||
|
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
||||||
|
# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет —
|
||||||
|
# там чистоту образа обеспечивает .dockerignore.
|
||||||
|
backend/tests/ export-ignore
|
||||||
|
backend/app/auth/dev_stub.py export-ignore
|
||||||
|
backend/app/routers/dev_auth.py export-ignore
|
||||||
|
backend/pyproject.toml export-ignore
|
||||||
|
README.md export-ignore
|
||||||
|
.gitignore export-ignore
|
||||||
|
.gitattributes export-ignore
|
||||||
|
.dockerignore export-ignore
|
||||||
|
|||||||
+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/ (образы)
|
|
||||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
|
|
||||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
docker-compose.yml
|
||||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
.env.example
|
||||||
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,117 +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-входа).
|
||||||
|
|
||||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||||
|
|
||||||
@@ -221,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).
|
|
||||||
|
|
||||||
## Дополнения и фракции
|
## Дополнения и фракции
|
||||||
|
|
||||||
@@ -257,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,50 +0,0 @@
|
|||||||
"""Вход администратора под защитой от перебора (#56).
|
|
||||||
|
|
||||||
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
|
||||||
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
|
||||||
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
|
|
||||||
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
|
|
||||||
включая IP.
|
|
||||||
"""
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
|
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||||
|
|
||||||
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
|
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
||||||
Telegram, /auth/login, /auth/register и dev-вход.
|
и dev-входом.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||||
Доступен во всех окружениях — наряду со входом по логину и паролю.
|
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+19
-83
@@ -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,21 +20,20 @@ class Settings(BaseSettings):
|
|||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Главный переключатель окружения: development | production ─────────────
|
# ── Главный переключатель окружения: development | test | production ───────
|
||||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
|
# 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).
|
||||||
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
|
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
||||||
# на фронте — оно приходит туда в GET /api/auth/config (#68).
|
|
||||||
app_tz_offset_hours: int = 3
|
app_tz_offset_hours: int = 3
|
||||||
|
|
||||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||||
@@ -61,15 +50,15 @@ 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
|
||||||
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
||||||
cookie_domain: str | None = None
|
cookie_domain: str | None = None
|
||||||
|
|
||||||
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
|
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
||||||
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
|
# prod: только telegram) — отдельного переключателя провайдера нет.
|
||||||
telegram_bot_token: str | None = None
|
telegram_bot_token: str | None = None
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
public_base_url: str | None = None
|
public_base_url: str | None = None
|
||||||
@@ -77,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, через запятую —
|
||||||
@@ -95,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
|
||||||
@@ -112,73 +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
|
|
||||||
|
|
||||||
@field_validator("app_tz_offset_hours")
|
|
||||||
@classmethod
|
|
||||||
def _known_tz_offset(cls, value: int) -> int:
|
|
||||||
"""Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время,
|
|
||||||
сдвинутое на сутки."""
|
|
||||||
if not -12 <= value <= 14:
|
|
||||||
raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _forbid_default_secrets_when_published(self) -> "Settings":
|
|
||||||
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
|
|
||||||
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
|
|
||||||
|
|
||||||
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
|
|
||||||
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
|
|
||||||
if not self.is_published:
|
|
||||||
return self
|
|
||||||
problems: list[str] = []
|
|
||||||
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
|
||||||
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, как отмечено в core/events.py
|
|
||||||
про 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,36 +135,9 @@ 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 клиента для журнала аудита.
|
||||||
|
|
||||||
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
|
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
|
||||||
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
|
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
|
||||||
return request.client.host if request.client else None
|
return request.client.host if request.client else None
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
||||||
|
|
||||||
|
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
|
||||||
|
где БД поднимается из чистого состояния.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from app.db.session import engine
|
||||||
|
from app.seed.reference_data import seed_reference_data
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_and_seed() -> None:
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
seed_reference_data(session)
|
||||||
+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):
|
||||||
|
|||||||
+43
-186
@@ -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,8 +68,14 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
|||||||
|
|
||||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _admin_user_read(u: User) -> s.AdminUserRead:
|
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||||
return s.AdminUserRead(
|
def list_users(
|
||||||
|
query: str | None = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
) -> list[s.AdminUserRead]:
|
||||||
|
return [
|
||||||
|
s.AdminUserRead(
|
||||||
id=u.id, # type: ignore[arg-type]
|
id=u.id, # type: ignore[arg-type]
|
||||||
nickname=u.nickname,
|
nickname=u.nickname,
|
||||||
role=u.role,
|
role=u.role,
|
||||||
@@ -94,17 +83,9 @@ def _admin_user_read(u: User) -> s.AdminUserRead:
|
|||||||
auth_provider=u.auth_provider,
|
auth_provider=u.auth_provider,
|
||||||
telegram_id=u.telegram_id,
|
telegram_id=u.telegram_id,
|
||||||
created_at=iso_utc(u.created_at),
|
created_at=iso_utc(u.created_at),
|
||||||
has_password=u.password_hash is not None,
|
|
||||||
)
|
)
|
||||||
|
for u in admin_service.list_users(session, query)
|
||||||
|
]
|
||||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
|
||||||
def list_users(
|
|
||||||
query: str | None = Query(None),
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
_admin: User = Depends(get_current_admin),
|
|
||||||
) -> list[s.AdminUserRead]:
|
|
||||||
return [_admin_user_read(u) 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"])
|
||||||
|
|
||||||
@@ -28,47 +26,9 @@ def auth_config() -> s.AuthConfig:
|
|||||||
return s.AuthConfig(
|
return s.AuthConfig(
|
||||||
methods=enabled_methods(),
|
methods=enabled_methods(),
|
||||||
telegram_bot_username=settings.telegram_bot_username,
|
telegram_bot_username=settings.telegram_bot_username,
|
||||||
tz_offset_hours=settings.app_tz_offset_hours,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
@@ -118,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).
|
||||||
|
|
||||||
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
||||||
|
|
||||||
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
||||||
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
|
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
|
||||||
без пароля остаётся только на деве (по логину и паролю входят везде).
|
входа по логину остаётся только на деве.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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
-134
@@ -1,52 +1,20 @@
|
|||||||
"""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
|
||||||
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
|
|
||||||
# показывает время всем игрокам — независимо от пояса устройства (#68).
|
|
||||||
tz_offset_hours: int
|
|
||||||
|
|
||||||
|
|
||||||
# Верхняя граница длины пароля на входе 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):
|
||||||
@@ -131,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):
|
||||||
@@ -172,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):
|
||||||
@@ -188,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):
|
||||||
@@ -236,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):
|
||||||
@@ -283,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
|
||||||
@@ -304,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 # оптимистичная блокировка
|
||||||
|
|
||||||
@@ -326,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
|
||||||
|
|
||||||
|
|
||||||
@@ -350,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):
|
||||||
@@ -372,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
|
||||||
@@ -395,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):
|
||||||
@@ -404,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):
|
||||||
@@ -440,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
|
||||||
|
|
||||||
|
|
||||||
@@ -505,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):
|
||||||
@@ -521,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):
|
||||||
@@ -577,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):
|
||||||
@@ -585,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
|
||||||
@@ -647,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:
|
||||||
|
match.played_at = played_at
|
||||||
|
if overall_comment_set:
|
||||||
|
match.overall_comment = overall_comment or None
|
||||||
|
if win_reason_set:
|
||||||
|
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||||
raise ValidationError("Некорректная причина победы.")
|
raise ValidationError("Некорректная причина победы.")
|
||||||
|
match.win_reason = win_reason
|
||||||
|
|
||||||
|
if participants is not None:
|
||||||
saved = session.exec(
|
saved = session.exec(
|
||||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||||
).all()
|
).all()
|
||||||
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
|
|
||||||
# с тем, что уже записано.
|
|
||||||
new_reason = win_reason if win_reason_set else match.win_reason
|
|
||||||
new_end_round = end_round if end_round_set else match.end_round
|
|
||||||
new_count = len(participants) if participants is not None else match.player_count
|
|
||||||
if end_round_set or participants is not None:
|
|
||||||
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
|
|
||||||
|
|
||||||
places: dict[int, int] = {}
|
|
||||||
worlds: dict[int, int | None] = {}
|
|
||||||
if participants is not None:
|
|
||||||
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||||
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||||
_validate_roster_basics(
|
_validate_roster_basics(
|
||||||
@@ -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 (
|
||||||
def _playable_match_ids():
|
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
|
||||||
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
|
FROM match_participants mp
|
||||||
|
JOIN matches m ON m.id = mp.match_id
|
||||||
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
|
WHERE m.status = 'finished' AND mp.place IS NOT NULL
|
||||||
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
|
GROUP BY mp.match_id, mp.place
|
||||||
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
|
),
|
||||||
return (
|
scored AS (
|
||||||
select(MatchParticipant.match_id)
|
SELECT mp.user_id AS user_id,
|
||||||
.group_by(MatchParticipant.match_id)
|
mp.faction_id AS faction_id,
|
||||||
.having(func.count() >= MIN_PARTICIPANTS)
|
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 load_history(session: Session) -> list[RatedMatch]:
|
def _round(value: Any, ndigits: int) -> float | None:
|
||||||
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
return None if value is None else round(float(value), ndigits)
|
||||||
|
|
||||||
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
|
|
||||||
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
|
|
||||||
_for_group по этой же истории: рейтинг считается только целиком."""
|
|
||||||
stmt = (
|
|
||||||
select(Match, MatchParticipant)
|
|
||||||
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
|
||||||
.where(Match.status == "finished")
|
|
||||||
.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
history: list[RatedMatch] = []
|
|
||||||
current: Match | None = None
|
|
||||||
seats: list[RatedSeat] = []
|
|
||||||
|
|
||||||
def flush() -> None:
|
|
||||||
if current is not None and len(seats) >= MIN_PARTICIPANTS:
|
|
||||||
history.append(
|
|
||||||
RatedMatch(
|
|
||||||
seats=tuple(seats),
|
|
||||||
win_reason=current.win_reason,
|
|
||||||
end_round=current.end_round,
|
|
||||||
nine_rounds_rule=current.nine_rounds_rule,
|
|
||||||
id=current.id, # type: ignore[arg-type]
|
|
||||||
group_id=current.group_id,
|
|
||||||
played_at=current.played_at,
|
|
||||||
finished_at=current.finished_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for m, p in session.exec(stmt).all():
|
|
||||||
if current is None or m.id != current.id:
|
|
||||||
flush()
|
|
||||||
current, seats = m, []
|
|
||||||
if p.place is None: # у завершённой партии мест без значения не бывает
|
|
||||||
continue
|
|
||||||
seats.append(
|
|
||||||
RatedSeat(
|
|
||||||
user_id=p.user_id,
|
|
||||||
place=p.place,
|
|
||||||
faction_id=p.faction_id,
|
|
||||||
eliminated=p.eliminated,
|
|
||||||
objectives=p.objectives,
|
|
||||||
worlds=p.worlds,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
flush()
|
|
||||||
return history
|
|
||||||
|
|
||||||
|
|
||||||
def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]:
|
def _normalize(row: dict) -> dict:
|
||||||
return [m for m in history if m.group_id == group_id]
|
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
|
||||||
|
avatar_url = None
|
||||||
|
if row.get("avatar_path"):
|
||||||
def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]:
|
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
|
||||||
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,
|
|
||||||
group_id,
|
|
||||||
history=history,
|
|
||||||
rep=rep,
|
|
||||||
member_ids={u.id for _m, u in members}, # type: ignore[misc]
|
|
||||||
)
|
)
|
||||||
last_played = max((m.played_at for m in group_history), default=None)
|
).one()
|
||||||
|
last_at = str(last_played) if last_played else None
|
||||||
|
|
||||||
games: dict[int, int] = defaultdict(int)
|
|
||||||
wins: dict[int, int] = defaultdict(int)
|
|
||||||
for m in group_history:
|
|
||||||
for s in m.seats:
|
|
||||||
games[s.faction_id] += 1
|
|
||||||
wins[s.faction_id] += s.place == 1
|
|
||||||
available_ids = group_service.available_faction_ids(session, group_id)
|
available_ids = group_service.available_faction_ids(session, group_id)
|
||||||
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
|
||||||
|
FROM factions f
|
||||||
|
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
|
||||||
|
GROUP BY f.id, f.code, f.name_ru
|
||||||
|
ORDER BY games DESC, f.sort_order
|
||||||
|
"""
|
||||||
|
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
|
||||||
|
for r in rows:
|
||||||
|
faction_meta.append(
|
||||||
{
|
{
|
||||||
"faction_id": f.id,
|
"faction_id": r["faction_id"],
|
||||||
"code": f.code,
|
"code": r["code"],
|
||||||
"name_ru": f.name_ru,
|
"name_ru": r["name_ru"],
|
||||||
"games": games[f.id], # type: ignore[index]
|
"games": int(r["games"] or 0),
|
||||||
"wins": wins[f.id], # type: ignore[index]
|
"wins": int(r["wins"] or 0),
|
||||||
"available": f.id in available_ids,
|
"available": r["faction_id"] in available_ids,
|
||||||
}
|
}
|
||||||
for f in factions
|
)
|
||||||
]
|
|
||||||
|
|
||||||
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
|
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
|
||||||
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
|
|
||||||
played_ids = {e["user_id"] for e in board["entries"]} | {
|
played_ids = {e["user_id"] for e in board["entries"]} | {
|
||||||
e["user_id"] for e in board["provisional"]
|
e["user_id"] for e in board["provisional"]
|
||||||
}
|
}
|
||||||
inactive = []
|
inactive = []
|
||||||
for _m, u in 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,159 +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
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("offset", [-13, 15])
|
|
||||||
def test_tz_offset_out_of_range_rejected(offset):
|
|
||||||
with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
|
|
||||||
config.Settings(app_env="development", app_tz_offset_hours=offset)
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
|
|
||||||
"""Фронт показывает время в поясе приложения — смещение приходит из настроек."""
|
|
||||||
from app.routers import auth
|
|
||||||
|
|
||||||
monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
|
|
||||||
assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
|
|
||||||
@@ -1,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(
|
|
||||||
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
|
|
||||||
).json()["id"]
|
).json()["id"]
|
||||||
|
|
||||||
gid, other_gid = group("Группа"), group("Другая")
|
|
||||||
p2 = add_group_member(engine, gid, "Игрок2")
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
outsider = add_group_member(engine, other_gid, "Чужой")
|
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
||||||
|
p3 = add_group_member(engine, gid, "Зритель")
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
return me, gid, p2, outsider, fids
|
|
||||||
|
|
||||||
|
|
||||||
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
|
|
||||||
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
started = start_match(
|
started = start_match(
|
||||||
@@ -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)
|
||||||
|
try:
|
||||||
r = client.get("/api/home")
|
r = client.get("/api/home")
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert len(calls) == 1
|
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,34 +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 — корректные сигналы; curl — отчёты и бот в Telegram (#83).
|
|
||||||
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
|
||||||
RUN apk add --no-cache sqlite supercronic tini curl
|
|
||||||
|
|
||||||
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
|
||||||
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
|
||||||
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"]
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +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')" "$*"; }
|
|
||||||
|
|
||||||
# Telegram-бот (#83): отвечает владельцу на /backups и /status. Стартует раньше проверки
|
|
||||||
# пароля — при выключенных бэкапах скажет, что они выключены. Упал — перезапуск через минуту.
|
|
||||||
if [ -n "${BACKUP_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${BACKUP_TELEGRAM_CHAT_ID:-}" ]; then
|
|
||||||
( while :; do fs-backup telegram bot || true; sleep 60; done ) &
|
|
||||||
else
|
|
||||||
log "Telegram не настроен (BACKUP_TELEGRAM_CHAT_ID/токен бота) — отчёты в Telegram выключены."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
|
||||||
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
|
||||||
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
|
||||||
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
|
|
||||||
# Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
|
|
||||||
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
|
|
||||||
# равно: guard_empty не сохранит БД без таблиц.
|
|
||||||
_app="${BACKUP_APP_HOST:-app}"
|
|
||||||
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
|
|
||||||
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
|
|
||||||
_waited=0
|
|
||||||
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
|
|
||||||
if [ "$_waited" -ge "$_wait" ]; then
|
|
||||||
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 10
|
|
||||||
_waited=$((_waited + 10))
|
|
||||||
done
|
|
||||||
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Расписание (TZ=${TZ:-UTC}):"
|
|
||||||
sed 's/^/[backup] /' "$CRONTAB"
|
|
||||||
exec supercronic -passthrough-logs "$CRONTAB"
|
|
||||||
File diff suppressed because it is too large
Load Diff
+10
-71
@@ -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 — туннель стучится наружу сам).
|
||||||
стучится наружу сам).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,32 +34,6 @@ newgrp docker # применить группу
|
|||||||
docker version && docker compose version # проверка
|
docker version && docker compose version # проверка
|
||||||
```
|
```
|
||||||
|
|
||||||
### Лимиты памяти (memory cgroup)
|
|
||||||
Прошивка Raspberry Pi сама добавляет ядру `cgroup_disable=memory`. Без контроллера `memory`
|
|
||||||
Docker **молча игнорирует** `mem_limit` из `docker-compose.yml` (app 512m, tunnel 64m, backup
|
|
||||||
384m) и на каждый контейнер пишет «Your kernel does not support memory limit capabilities or
|
|
||||||
the cgroup is not mounted. Limitation discarded.». Тогда утечка или тяжёлый бэкап могут
|
|
||||||
занять всю RAM Pi, и OOM-killer прибьёт что попало.
|
|
||||||
|
|
||||||
Проверка — если есть вывод, лимиты не работают:
|
|
||||||
```bash
|
|
||||||
docker info 2>&1 | grep -i "no memory limit"
|
|
||||||
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
|
|
||||||
```
|
|
||||||
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки
|
|
||||||
в этом файле ломает загрузку) и перезагрузить Pi. Прод на время перезагрузки недоступен,
|
|
||||||
контейнеры поднимутся сами:
|
|
||||||
```bash
|
|
||||||
sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak
|
|
||||||
grep -q "cgroup_enable=memory" /boot/firmware/cmdline.txt || \
|
|
||||||
sudo sed -i '1 s/$/ cgroup_enable=memory/' /boot/firmware/cmdline.txt
|
|
||||||
cat /boot/firmware/cmdline.txt # одна строка, в конце cgroup_enable=memory
|
|
||||||
sudo reboot
|
|
||||||
```
|
|
||||||
После перезагрузки `cgroup.controllers` содержит `memory`, а `docker info` не пишет
|
|
||||||
`No memory limit support`. Если Pi не загрузился, верните бэкап: вставьте карту в ПК и на
|
|
||||||
разделе `bootfs` замените `cmdline.txt` содержимым `cmdline.txt.bak`.
|
|
||||||
|
|
||||||
## 2. SSH-ключ для туннеля
|
## 2. SSH-ключ для туннеля
|
||||||
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
||||||
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
||||||
@@ -113,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
|
||||||
@@ -138,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` — должно отдать приложение (не заглушку).
|
||||||
@@ -148,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`
|
||||||
@@ -180,6 +122,3 @@ docker compose exec backup fs-backup list # хронология снимк
|
|||||||
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
||||||
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
||||||
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
||||||
- `docker compose up` пишет «Your kernel does not support memory limit capabilities…
|
|
||||||
Limitation discarded.» → лимиты памяти не работают: включите memory cgroup, раздел 1,
|
|
||||||
«Лимиты памяти».
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+6
-36
@@ -1,17 +1,12 @@
|
|||||||
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
||||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||||
#
|
#
|
||||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) 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:
|
||||||
+5
-55
@@ -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
|
||||||
@@ -21,8 +17,8 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
|
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется
|
||||||
# .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
|
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi).
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
||||||
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -42,9 +38,7 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
# Лимиты под Pi; переопределяются в .env. mem_limit действует, только если на Pi включён
|
mem_limit: ${APP_MEM_LIMIT:-512m} # лимиты под Pi; переопределяются в .env
|
||||||
# memory cgroup — иначе Docker его молча игнорирует (deploy/pi/README.md, раздел 1).
|
|
||||||
mem_limit: ${APP_MEM_LIMIT:-512m}
|
|
||||||
cpus: ${APP_CPUS:-1.5}
|
cpus: ${APP_CPUS:-1.5}
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -75,51 +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_* (секреты приложения сюда не передаются; исключение — токен бота для
|
|
||||||
# отчётов в Telegram). Всё про настройку и восстановление — 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:-}
|
|
||||||
# Отчёты и бот в Telegram (#83). Единственное исключение из «секреты приложения сюда
|
|
||||||
# не передаются»: по умолчанию — токен бота приложения (тот же ForbidenStarsBot).
|
|
||||||
BACKUP_TELEGRAM_BOT_TOKEN: ${BACKUP_TELEGRAM_BOT_TOKEN:-${TELEGRAM_BOT_TOKEN:-}}
|
|
||||||
BACKUP_TELEGRAM_CHAT_ID: ${BACKUP_TELEGRAM_CHAT_ID:-}
|
|
||||||
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
|
||||||
volumes:
|
|
||||||
- db-data:/fs-db # живая БД (снимается консистентно)
|
|
||||||
- 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,33 +1,15 @@
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import { queryClient } from "./app/queryClient";
|
import { queryClient } from "./app/queryClient";
|
||||||
import { router } from "./app/router";
|
import { router } from "./app/router";
|
||||||
import { Spinner } from "./components/Spinner";
|
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
import { setAppTzOffsetHours } from "./domain/format";
|
|
||||||
import { useAuthConfig } from "./hooks/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
|
|
||||||
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
|
|
||||||
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
|
|
||||||
*/
|
|
||||||
function AppTimeZone({ children }: { children: ReactNode }) {
|
|
||||||
const { data, isPending } = useAuthConfig();
|
|
||||||
if (data) setAppTzOffsetHours(data.tz_offset_hours);
|
|
||||||
if (isPending) return <Spinner />;
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AppTimeZone>
|
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</AppTimeZone>
|
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
-777
@@ -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 */
|
||||||
@@ -1610,8 +1307,6 @@ export interface components {
|
|||||||
methods: string[];
|
methods: string[];
|
||||||
/** Telegram Bot Username */
|
/** Telegram Bot Username */
|
||||||
telegram_bot_username?: string | null;
|
telegram_bot_username?: string | null;
|
||||||
/** Tz Offset Hours */
|
|
||||||
tz_offset_hours: number;
|
|
||||||
};
|
};
|
||||||
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
||||||
Body_add_attachment_api_matches__match_id__attachments_post: {
|
Body_add_attachment_api_matches__match_id__attachments_post: {
|
||||||
@@ -1771,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: {
|
||||||
@@ -1785,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 */
|
||||||
@@ -1816,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 */
|
||||||
@@ -1916,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: {
|
||||||
@@ -1937,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 */
|
||||||
@@ -1970,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: {
|
||||||
@@ -2015,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
|
||||||
@@ -2073,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 */
|
||||||
@@ -2085,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: {
|
||||||
@@ -2109,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: {
|
||||||
@@ -2135,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;
|
||||||
};
|
};
|
||||||
@@ -2162,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 */
|
||||||
@@ -2204,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 */
|
||||||
@@ -2247,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: {
|
||||||
@@ -2354,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: {
|
||||||
@@ -2588,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;
|
||||||
@@ -2826,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;
|
||||||
@@ -3241,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;
|
||||||
@@ -3252,7 +2730,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["GroupUpdate"];
|
"application/json": components["schemas"]["GroupRename"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
@@ -4172,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;
|
||||||
@@ -4366,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;
|
||||||
@@ -4923,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,11 +53,10 @@ 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={
|
||||||
@@ -78,9 +76,6 @@ export function MatchHistory({
|
|||||||
<span className="muted small" style={{ flexShrink: 0 }}>
|
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||||
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
|
|
||||||
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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,24 +225,7 @@ export function PlaceEditor({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const countInput = (userId: number, field: CountField, label: string, locked = false) => (
|
const commentInput = (userId: number) => (
|
||||||
<label className="rank-count">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
max={MAX_COUNT}
|
|
||||||
placeholder="—"
|
|
||||||
disabled={locked}
|
|
||||||
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
|
||||||
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
const commentInput = (userId: number) => {
|
|
||||||
const comment = (
|
|
||||||
<input
|
<input
|
||||||
className="rank-comment"
|
className="rank-comment"
|
||||||
placeholder="Комментарий об игроке"
|
placeholder="Комментарий об игроке"
|
||||||
@@ -260,15 +233,6 @@ export function PlaceEditor({
|
|||||||
onChange={(e) => onComment(userId, e.target.value)}
|
onChange={(e) => onComment(userId, e.target.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (!counts) return comment;
|
|
||||||
return (
|
|
||||||
<div className="rank-details">
|
|
||||||
{comment}
|
|
||||||
{countInput(userId, "objectives", "цели")}
|
|
||||||
{countInput(userId, "worlds", "миры", eliminated.includes(userId))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,12 @@
|
|||||||
// Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
|
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
|
||||||
// Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
|
const APP_TZ_OFFSET_MIN = 3 * 60;
|
||||||
// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
|
|
||||||
// 3 ч (МСК) — запасное значение, если конфиг недоступен.
|
|
||||||
let appTzOffsetMin = 3 * 60;
|
|
||||||
|
|
||||||
export function setAppTzOffsetHours(hours: number): void {
|
|
||||||
appTzOffsetMin = hours * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
|
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
|
||||||
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
|
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
|
||||||
function shifted(iso: string): Date {
|
function shifted(iso: string): Date {
|
||||||
return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
|
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
||||||
@@ -25,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);
|
||||||
@@ -57,22 +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"> — «настенное» время в том же поясе приложения, что
|
|
||||||
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
|
|
||||||
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())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обратно: «настенное» время пояса приложения из 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]) - appTzOffsetMin * 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"];
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user