Compare commits
159
Commits
90d0eb2546
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b33b442e9 | ||
|
|
d12afe62d0 | ||
|
|
30270916a4 | ||
|
|
2f0b22ccd4 | ||
|
|
89cbb81be7 | ||
|
|
f5f8dc03ce | ||
|
|
ec1e94119c | ||
|
|
8d4f7a95df | ||
|
|
5b89dc9d2f | ||
|
|
00bd3c72b6 | ||
|
|
f7f74fee8c | ||
|
|
643a9f1795 | ||
|
|
709b864c60 | ||
|
|
03834c6855 | ||
|
|
0ee7cd1274 | ||
|
|
1bc3940ee8 | ||
|
|
164db495b4 | ||
|
|
cff48f7cc7 | ||
|
|
bb5f2a1121 | ||
|
|
63a2cddd5e | ||
|
|
5035ee41e6 | ||
|
|
6c35cb04d4 | ||
|
|
63f541d61f | ||
|
|
7829a49e9d | ||
|
|
ec0445fbcb | ||
|
|
ab1fc3f00f | ||
|
|
a0a0e522ef | ||
|
|
2e496a3f21 | ||
|
|
526e4adf0c | ||
|
|
1e04f132b8 | ||
|
|
640f712037 | ||
|
|
1c391448c1 | ||
|
|
25c3c5ba4d | ||
|
|
6f54e0f9df | ||
|
|
ba69b9f21d | ||
|
|
171aebb6f8 | ||
|
|
adc47beb72 | ||
|
|
cc64bb4644 | ||
|
|
6ea4a67672 | ||
|
|
8aa6bb7235 | ||
|
|
e58b4f6614 | ||
|
|
e40d93e7be | ||
|
|
28846c561c | ||
|
|
af7d2b358d | ||
|
|
101c457f7e | ||
|
|
dafbf5bad4 | ||
|
|
63fd90e017 | ||
|
|
7579ca2d35 | ||
|
|
8b400b76c6 | ||
|
|
19f6068ddc | ||
|
|
d764ff86d3 | ||
|
|
e2d729d339 | ||
|
|
f73316f37f | ||
|
|
648c10f2a7 | ||
|
|
8f1518b0b3 | ||
|
|
37fe286db9 | ||
|
|
ec65f104ee | ||
|
|
ce7fbad58e | ||
|
|
0799aee684 | ||
|
|
112b583264 | ||
|
|
038a788f99 | ||
|
|
706eeb0af1 | ||
|
|
3f8667b561 | ||
|
|
d84cef2491 | ||
|
|
3587fc8af2 | ||
|
|
0cbee9d866 | ||
|
|
1056d81b87 | ||
|
|
c0e162df72 | ||
|
|
a09766c031 | ||
|
|
462079d835 | ||
|
|
c73b4519cb | ||
|
|
82cf7a3393 | ||
|
|
5efce76cd9 | ||
|
|
cb3af484cd | ||
|
|
65561155b4 | ||
|
|
7b3edcc7e9 | ||
|
|
b4663796a3 | ||
|
|
aabcc106d3 | ||
|
|
29a0e17fd1 | ||
|
|
a94de6492a | ||
|
|
bdd07e8103 | ||
|
|
4133c3c505 | ||
|
|
e0f1ed5fee | ||
|
|
634a4c0e08 | ||
|
|
f2aaa2a15e | ||
|
|
f3067b1f89 | ||
|
|
ea743ccac0 | ||
|
|
e5b3505bc2 | ||
|
|
0d81901a63 | ||
|
|
2986a58ef1 | ||
|
|
d41391eb69 | ||
|
|
763745a223 | ||
|
|
32d64c94af | ||
|
|
8be9a23c0c | ||
|
|
225e62fc1f | ||
|
|
a9de68f27c | ||
|
|
94e2c09952 | ||
|
|
8ac2757cd6 | ||
|
|
1831a3e033 | ||
|
|
c4b72cca39 | ||
|
|
62d75ea176 | ||
|
|
9d9e4a345a | ||
|
|
530cf3f4cd | ||
|
|
d0f01a4cc2 | ||
|
|
f031bd5e89 | ||
|
|
9dda7de067 | ||
|
|
be80891449 | ||
|
|
bca2d7a058 | ||
|
|
45f2b2007f | ||
|
|
1790e195e0 | ||
|
|
befabba752 | ||
|
|
60b22dd8df | ||
|
|
b3e4f71bda | ||
|
|
309e31a49a | ||
|
|
534ba640cc | ||
|
|
09be0f57c6 | ||
|
|
2a4c5d9d25 | ||
|
|
81ad2a0403 | ||
|
|
5e4e4835f2 | ||
|
|
c8d5efea65 | ||
|
|
9ce501dd11 | ||
|
|
e7323cc3c4 | ||
|
|
b349891bdb | ||
|
|
3023e9f515 | ||
|
|
376605f554 | ||
|
|
4b7d8678ce | ||
|
|
5c3aa6ba69 | ||
|
|
1dbff7ae58 | ||
|
|
48a46ad57d | ||
|
|
93d81010f7 | ||
|
|
8a2b53d0b2 | ||
|
|
f22ee1ec29 | ||
|
|
4a681430af | ||
|
|
45bdadece9 | ||
|
|
ebb368686d | ||
|
|
ddbcd74058 | ||
|
|
e5b7420bae | ||
|
|
fc1b1fd912 | ||
|
|
dbc32b7f4d | ||
|
|
ac296a8f12 | ||
|
|
c4f75d5de0 | ||
|
|
378094c366 | ||
|
|
49864db87e | ||
|
|
28852f9d38 | ||
|
|
ca96073ccf | ||
|
|
e38555b74d | ||
|
|
002c657855 | ||
|
|
7b61443fb9 | ||
|
|
d2c0feafcc | ||
|
|
cbf3cfc6e6 | ||
|
|
d81f5d8964 | ||
|
|
029a043bfc | ||
|
|
c544f72063 | ||
|
|
886ff5d4e2 | ||
|
|
1a61b05923 | ||
|
|
b3db5a4047 | ||
|
|
32c74abc43 | ||
|
|
1e19038b7c | ||
|
|
f56f9eb47b |
+14
-5
@@ -1,13 +1,23 @@
|
||||
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
|
||||
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.egg-info/
|
||||
backend/.venv/
|
||||
backend/.pytest_cache/
|
||||
backend/*.db
|
||||
backend/*.db-wal
|
||||
backend/*.db-shm
|
||||
backend/openapi.json
|
||||
|
||||
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
|
||||
# локальные копии бэкапов — ни в образ, ни в контекст сборки
|
||||
backend/data/
|
||||
data/
|
||||
backups/
|
||||
**/*.db
|
||||
**/*.db-wal
|
||||
**/*.db-shm
|
||||
|
||||
# Node / сборка фронта
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
@@ -23,8 +33,7 @@ backend/tests/
|
||||
backend/pyproject.toml
|
||||
|
||||
# Прочее
|
||||
.env
|
||||
data/
|
||||
**/.env
|
||||
**/.DS_Store
|
||||
*.md
|
||||
.claude/
|
||||
|
||||
+91
-16
@@ -1,13 +1,12 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Forbidden Stars — единый .env (dev / test / prod)
|
||||
# Forbidden Stars — единый .env (dev / prod)
|
||||
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
|
||||
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
||||
# test — прод-клон в Docker локально (порт 8080), вход только TG
|
||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
|
||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||
APP_ENV=development
|
||||
@@ -15,54 +14,130 @@ APP_ENV=development
|
||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||
# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им
|
||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают.
|
||||
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
|
||||
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
|
||||
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
|
||||
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
||||
LOCAL_PUBLIC=local
|
||||
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod.
|
||||
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода.
|
||||
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
|
||||
VPS_TUNNEL_HOST=186.246.51.17
|
||||
VPS_TUNNEL_USER=tunnel
|
||||
VPS_TUNNEL_PORT=9001 # слот VPS для dev-туннеля (forbidden-stars.ru); контейнеры: prod=9000, test=9001
|
||||
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
|
||||
# dev (run.ps1) → 9001, прод-контейнер → 9000.
|
||||
# Раскомментируй и переопредели, только если нужен нестандартный слот.
|
||||
#VPS_TUNNEL_PORT=9001
|
||||
|
||||
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
|
||||
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод
|
||||
# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
|
||||
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
|
||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel")))
|
||||
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
|
||||
TUNNEL_KEY_B64=
|
||||
|
||||
# ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ──────────────
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
ADMIN_NICKNAME=Администратор
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
|
||||
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
||||
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
|
||||
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
||||
# и forbidden-stars.ru (dev/test).
|
||||
# и forbidden-stars.ru (dev).
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_BOT_USERNAME=
|
||||
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru.
|
||||
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru.
|
||||
PUBLIC_BASE_URL=
|
||||
|
||||
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
|
||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
|
||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_USER_TTL_MINUTES=10080
|
||||
JWT_ADMIN_TTL_MINUTES=480
|
||||
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
|
||||
# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно.
|
||||
# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
|
||||
# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL
|
||||
# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml).
|
||||
# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL
|
||||
# (том /data контейнера, см. docker-compose*.yml).
|
||||
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
|
||||
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
||||
|
||||
# Каталог загрузок (зарезервировано под фото/видео), тоже раздельно.
|
||||
# Каталог загрузок (фото партий), раздельно для dev и prod.
|
||||
DEV_UPLOAD_DIR=./data/dev/uploads
|
||||
PROD_UPLOAD_DIR=/data/uploads
|
||||
|
||||
# Каталог определений титулов (файлы: meta.json, condition.py, icon.*), раздельно.
|
||||
DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
|
||||
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
||||
|
||||
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
||||
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
|
||||
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
||||
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
||||
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
||||
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
||||
# Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён
|
||||
# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1.
|
||||
#APP_MEM_LIMIT=512m
|
||||
#APP_CPUS=1.5
|
||||
|
||||
# ─── БЭКАПЫ (контейнер backup: restic; подробно — deploy/backup/README.md) ────
|
||||
# Снимки БД + uploads + achievements по расписанию: локально на Pi (том backup-data) и —
|
||||
# если задан BACKUP_VPS_HOST — на VPS по SFTP. Шифрование, дедупликация, сжатие — restic.
|
||||
#
|
||||
# Пароль шифрования репозиториев. ПОТЕРЯЕТЕ ПАРОЛЬ — НИ ОДИН БЭКАП НЕ ПРОЧИТАТЬ.
|
||||
# Сохраните его в менеджер паролей. Пусто = бэкапы отключены. Без символа '$'.
|
||||
# Сгенерировать: 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_USER=fsbackup
|
||||
BACKUP_VPS_PORT=22
|
||||
BACKUP_VPS_DIR=/srv/fs-backups/restic
|
||||
# Приватный SSH-ключ для VPS в base64 одной строкой (как TUNNEL_KEY_B64). На ПК ключ лежит
|
||||
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
||||
BACKUP_SSH_KEY_B64=
|
||||
# Отчёты о бэкапах в Telegram и команды /backups, /status (deploy/backup/README.md, раздел 10).
|
||||
# id своего чата: напишите боту /start → `docker compose exec backup fs-backup telegram chats`. Несколько — через
|
||||
# запятую. Пусто = отчёты выключены. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения.
|
||||
BACKUP_TELEGRAM_CHAT_ID=
|
||||
#BACKUP_TELEGRAM_BOT_TOKEN=
|
||||
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
||||
# docker-compose.yml прода.
|
||||
BACKUP_PI_SSH=pi@192.168.1.10
|
||||
BACKUP_PI_DIR=~/forbidden-stars
|
||||
|
||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
||||
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
|
||||
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
|
||||
# пояс устройства игрока не учитывается.
|
||||
APP_TZ_OFFSET_HOURS=3
|
||||
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
+1
-16
@@ -1,20 +1,5 @@
|
||||
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
||||
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
|
||||
# entrypoint.sh контейнер чинит сам (sed в Dockerfile), но backup.sh запускается
|
||||
# с хоста Pi — для него LF в репозитории критичен.
|
||||
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
||||
*.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
|
||||
|
||||
+7
-2
@@ -15,7 +15,7 @@ venv/
|
||||
data/
|
||||
backend/dev.db*
|
||||
|
||||
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod)
|
||||
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod)
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
@@ -23,6 +23,7 @@ backend/dev.db*
|
||||
*.pem
|
||||
*.key
|
||||
id_tunnel*
|
||||
id_backup*
|
||||
deploy/**/certs/
|
||||
|
||||
# Node / фронт
|
||||
@@ -34,11 +35,15 @@ frontend/.vite/
|
||||
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
|
||||
backend/openapi.json
|
||||
|
||||
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
||||
# Бэкапы, скачанные на ПК (scripts/fs-backup.* pull) — не зашифрованы, в git не идут
|
||||
backups/
|
||||
|
||||
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
||||
mockups/
|
||||
|
||||
# AI-ассистенты (локальные, в репозиторий не идут)
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
# Редактор / ОС
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
# Forbidden Stars — учёт партий
|
||||
|
||||
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
||||
профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий
|
||||
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
||||
профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями,
|
||||
создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий,
|
||||
уведомления, статистика и общий топ, админ-панель.
|
||||
|
||||
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
||||
- **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API)
|
||||
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker
|
||||
- **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые
|
||||
обновления приходят SSE-потоком `/api/events`)
|
||||
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация»)
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
backend/ FastAPI: ядро, REST API, БД, миграции, seed
|
||||
frontend/ React + Vite SPA
|
||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||
docker-compose.yml
|
||||
.env.example
|
||||
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
||||
frontend/ React + Vite SPA
|
||||
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
|
||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
||||
run.ps1 / run.sh единый лаунчер dev
|
||||
.env.example шаблон единого .env
|
||||
```
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
Всё проверяется в `development` — отдельного тестового контейнера нет.
|
||||
|
||||
### Единый лаунчер (`run.ps1` / `run.sh`)
|
||||
|
||||
После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно,
|
||||
решает `APP_ENV` в корневом `.env`:
|
||||
После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает
|
||||
`APP_ENV` в корневом `.env`):
|
||||
|
||||
```powershell
|
||||
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
|
||||
@@ -31,9 +39,9 @@ docker-compose.yml
|
||||
|
||||
| `APP_ENV` в `.env` | что делает лаунчер |
|
||||
|---|---|
|
||||
| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах |
|
||||
| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) |
|
||||
| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») |
|
||||
| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` |
|
||||
| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») |
|
||||
| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) |
|
||||
|
||||
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
|
||||
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
|
||||
@@ -47,9 +55,9 @@ python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||
pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости)
|
||||
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
|
||||
alembic upgrade head # применит миграции и сидинг
|
||||
python -m app.bootstrap # создаст/синхронизирует администратора из .env
|
||||
uvicorn app.main:app --reload # http://localhost:8000 (Swagger: /api/docs)
|
||||
alembic upgrade head # применит миграции и сидинг справочников
|
||||
python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально)
|
||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
|
||||
```
|
||||
|
||||
**Windows cmd.exe** (здесь `&&` поддерживается):
|
||||
@@ -61,7 +69,7 @@ pip install -e ".[dev]"
|
||||
copy ..\.env.example ..\.env
|
||||
alembic upgrade head
|
||||
python -m app.bootstrap
|
||||
uvicorn app.main:app --reload
|
||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
||||
```
|
||||
|
||||
**Linux / macOS / Git Bash:**
|
||||
@@ -72,9 +80,17 @@ pip install -e ".[dev]"
|
||||
cp ../.env.example ../.env
|
||||
alembic upgrade head
|
||||
python -m app.bootstrap
|
||||
uvicorn app.main:app --reload
|
||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
||||
```
|
||||
|
||||
> В dev тот же bootstrap (справочники + админ из `.env`) выполняется сам при старте uvicorn,
|
||||
> поэтому `python -m app.bootstrap` вручную обычно не нужен. Dev-БД, загрузки и ачивки
|
||||
> лежат в `backend/data/dev/` (пути в `.env` относительные — запускайте из `backend/`).
|
||||
|
||||
> **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
|
||||
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
|
||||
> а в логе только `Reloading...`.
|
||||
|
||||
> **Единый `.env` — в корне репозитория** (`ForbidenStarsApp/.env`), рядом с `.env.example`.
|
||||
> Его читают и бэкенд (через абсолютный путь, независимо от рабочей папки), и `docker compose`.
|
||||
> Файл — локальный, на каждой машине свой (dev/prod различаются строкой `APP_ENV`).
|
||||
@@ -87,100 +103,117 @@ npm run gen:api # сгенерирует типы из живог
|
||||
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
||||
```
|
||||
|
||||
Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику),
|
||||
в проде — только Telegram (см. раздел «Аутентификация»).
|
||||
Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub);
|
||||
в проде stub нет (см. раздел «Аутентификация»).
|
||||
|
||||
## Production (Docker на Pi)
|
||||
|
||||
На Pi нужны только **два файла** — `docker-compose.yml` и `.env`: образы `app`, `tunnel` и
|
||||
`backup` собираются на ПК под arm64 и пушатся в Gitea-реестр, Pi тянет их сам.
|
||||
|
||||
```powershell
|
||||
# ПК (обычно с ветки main): собрать и опубликовать образы
|
||||
docker login gitea.arseniev.info
|
||||
.\scripts\build-push.ps1
|
||||
```
|
||||
```bash
|
||||
cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр.
|
||||
docker compose build # на ARM64 собирается нативно
|
||||
docker compose up -d # приложение на :8000, БД на томе
|
||||
# Pi, папка с docker-compose.yml и .env
|
||||
docker compose up -d # pull_policy: always — тянет свежие образы, без сборки
|
||||
```
|
||||
|
||||
FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа
|
||||
выполняются автоматически при старте (`entrypoint.sh`).
|
||||
Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через
|
||||
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
||||
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
||||
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
||||
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
|
||||
при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и
|
||||
`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские
|
||||
сессии завершатся; подробно — в `deploy/pi/README.md`).
|
||||
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||
|
||||
## Test — локальный прод-клон в контейнере
|
||||
|
||||
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
|
||||
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
||||
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
||||
|
||||
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
||||
Вручную (тот же эффект):
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up -d --build
|
||||
# открыть http://localhost:8080 (Swagger: /api/docs)
|
||||
docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные
|
||||
```
|
||||
|
||||
- Окружение `test` (прод-клон), но `COOKIE_SECURE=false` (локально по HTTP).
|
||||
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
||||
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
||||
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
||||
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** —
|
||||
только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`).
|
||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||
и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
|
||||
- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` —
|
||||
поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`,
|
||||
тот же слот VPS 9000, что у Pi (одновременно не запускать).
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Методы входа зависят от окружения (`APP_ENV`):
|
||||
|
||||
| | dev | test / prod |
|
||||
| | dev | prod |
|
||||
|---|---|---|
|
||||
| Telegram Login Widget | ✓ | ✓ (единственный) |
|
||||
| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
|
||||
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
||||
| Telegram Login Widget | ✓ | ✓ |
|
||||
| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) |
|
||||
|
||||
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
||||
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
||||
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
||||
(`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`).
|
||||
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`).
|
||||
`GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета.
|
||||
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
|
||||
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
|
||||
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и
|
||||
50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше
|
||||
10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа
|
||||
видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или
|
||||
выйти; в dev-сборке есть кнопка «Позже (dev)». В профиле пароль меняется (нужен текущий) и
|
||||
привязывается Telegram (ник не меняется). Забытый пароль задаёт админ на вкладке
|
||||
аккаунтов — почту приложение не хранит. Смена или сброс пароля завершает все прежние
|
||||
сессии игрока (при смене в профиле текущее устройство остаётся в системе); «Выйти»
|
||||
отзывает токен этого устройства.
|
||||
- **Stub-вход (по нику)** и **жёсткое удаление аккаунтов** в админке — только для разработки.
|
||||
Их код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py`,
|
||||
`backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из
|
||||
Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development`
|
||||
(`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`).
|
||||
В проде аккаунт можно только отключить.
|
||||
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть
|
||||
данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник
|
||||
занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные
|
||||
методы и `telegram_bot_username` для виджета.
|
||||
|
||||
**Настройка Telegram (когда будете подключать реальный вход):**
|
||||
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
|
||||
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test).
|
||||
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev).
|
||||
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
|
||||
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
|
||||
|
||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях.
|
||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех
|
||||
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
|
||||
|
||||
## Окружения (dev / test / prod)
|
||||
## Окружения (dev / prod)
|
||||
|
||||
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер):
|
||||
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только
|
||||
`development` и `production` — с любым другим значением бэкенд не стартует:
|
||||
|
||||
| | dev | test (прод-клон локально) | prod (Pi) |
|
||||
|---|---|---|---|
|
||||
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` |
|
||||
| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) |
|
||||
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
||||
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
||||
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
||||
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
|
||||
| | dev | prod (Pi) |
|
||||
|---|---|---|
|
||||
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` |
|
||||
| `APP_ENV` | `development` | `production` (форсится в compose) |
|
||||
| Env-файл | единый `.env` | единый `.env` (на Pi) |
|
||||
| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` |
|
||||
| База данных | `backend/data/dev/…` | том `db-data` (`/data`) |
|
||||
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram |
|
||||
| Swagger (`/api/docs`) | ✓ | ✗ |
|
||||
| Fail-fast по дефолтным секретам | ✗ | ✓ |
|
||||
|
||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
||||
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
||||
`production`. Отдельного `.env.test` больше нет.
|
||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение
|
||||
`APP_ENV` из него **игнорирует** и всегда `production`.
|
||||
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
|
||||
(`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это
|
||||
РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`).
|
||||
- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа
|
||||
(`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`.
|
||||
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
||||
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
||||
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
||||
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
|
||||
и локальные бэкапы (`backups/`) в образ тоже не попадают.
|
||||
|
||||
## Git и деплой
|
||||
|
||||
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь.
|
||||
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь.
|
||||
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
|
||||
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
|
||||
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
|
||||
- **Деплой на Pi:** `git pull` ветки `main` → `docker compose up -d --build`.
|
||||
- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh <dir> main` (через
|
||||
`git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа).
|
||||
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
||||
|
||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||
|
||||
@@ -188,21 +221,32 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
|
||||
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
|
||||
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
|
||||
У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост
|
||||
они не публикуют (доступны только через домен):
|
||||
У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он
|
||||
не публикует (доступен только через домен):
|
||||
|
||||
| | домен | как выставляется | слот VPS |
|
||||
|---|---|---|---|
|
||||
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
|
||||
| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 |
|
||||
| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||
|
||||
- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают
|
||||
одновременно. Dev и test делят слот 9001 → по очереди.
|
||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен.
|
||||
- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный
|
||||
прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
|
||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
|
||||
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
|
||||
время проверки (задача #69).
|
||||
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
|
||||
- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi —
|
||||
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
|
||||
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
|
||||
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
|
||||
`pi/` (app + туннель в Docker), образ туннеля — `deploy/tunnel/`. Ключ — `deploy/tunnel/id_tunnel`.
|
||||
`pi/` (app + туннель + бэкапы в Docker), образ туннеля — `deploy/tunnel/`.
|
||||
|
||||
## Бэкапы
|
||||
|
||||
Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок
|
||||
БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки
|
||||
скачиваются со сверкой sha256 (`scripts/fs-backup.ps1 pull`).
|
||||
Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||
|
||||
## Дополнения и фракции
|
||||
|
||||
@@ -213,4 +257,6 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
| Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
|
||||
|
||||
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
|
||||
этих дополнений (база — всегда).
|
||||
этих дополнений (база — всегда). Справочник сидится из `backend/app/seed/reference_data.py`.
|
||||
Админ может переименовать фракцию в панели, но сейчас переименование откатывается при
|
||||
каждом перезапуске приложения (задача #72).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Пользователь: любимая фракция (личный выбор в профиле).
|
||||
|
||||
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
|
||||
|
||||
Revision ID: 0010_user_favorite_faction
|
||||
Revises: 0009_participant_eliminated
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0010_user_favorite_faction"
|
||||
down_revision: Union[str, None] = "0009_participant_eliminated"
|
||||
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")}
|
||||
if "favorite_faction_id" not in cols:
|
||||
with op.batch_alter_table("users") as b:
|
||||
b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True))
|
||||
b.create_foreign_key(
|
||||
"fk_users_favorite_faction_id",
|
||||
"factions",
|
||||
["favorite_faction_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
cols = {c["name"] for c in insp.get_columns("users")}
|
||||
if "favorite_faction_id" in cols:
|
||||
with op.batch_alter_table("users") as b:
|
||||
b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey")
|
||||
b.drop_column("favorite_faction_id")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Пользователь: настройки витрины истории партий в профиле.
|
||||
|
||||
Идемпотентна: на свежей БД столбцы создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||
на существующей БД добавляет столбцы. render_as_batch включён в env.py (для SQLite).
|
||||
|
||||
Revision ID: 0011_user_history_prefs
|
||||
Revises: 0010_user_favorite_faction
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0011_user_history_prefs"
|
||||
down_revision: Union[str, None] = "0010_user_favorite_faction"
|
||||
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 "history_mode" not in cols:
|
||||
b.add_column(
|
||||
sa.Column(
|
||||
"history_mode", sa.String(8), nullable=False, server_default="all"
|
||||
)
|
||||
)
|
||||
if "history_detail" not in cols:
|
||||
b.add_column(
|
||||
sa.Column(
|
||||
"history_detail", sa.String(8), nullable=False, server_default="compact"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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 "history_detail" in cols:
|
||||
b.drop_column("history_detail")
|
||||
if "history_mode" in cols:
|
||||
b.drop_column("history_mode")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Партия: общий черновик формы завершения (совместное заполнение результатов).
|
||||
|
||||
Идемпотентна: на свежей БД таблицу создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||
на существующей БД создаёт таблицу.
|
||||
|
||||
Revision ID: 0012_match_finish_draft
|
||||
Revises: 0011_user_history_prefs
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_match_finish_draft"
|
||||
down_revision: Union[str, None] = "0011_user_history_prefs"
|
||||
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)
|
||||
if "match_finish_drafts" in insp.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"match_finish_drafts",
|
||||
sa.Column(
|
||||
"match_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("matches.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("data", sa.JSON(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
if "match_finish_drafts" in insp.get_table_names():
|
||||
op.drop_table("match_finish_drafts")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Пользователь: версия сессий (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")
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
|
||||
|
||||
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
|
||||
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
|
||||
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
|
||||
last_standing.
|
||||
|
||||
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
|
||||
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
|
||||
|
||||
Revision ID: 0014_rating_inputs
|
||||
Revises: 0013_user_token_version
|
||||
Create Date: 2026-09-14
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_rating_inputs"
|
||||
down_revision: Union[str, None] = "0013_user_token_version"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
CK_WIN_REASON = "ck_match_win_reason"
|
||||
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
|
||||
NEW_REASONS = (
|
||||
"win_reason IS NULL OR win_reason IN "
|
||||
"('objectives','worlds','plastic','resources','last_standing')"
|
||||
)
|
||||
|
||||
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
||||
# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта
|
||||
# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была.
|
||||
BACKFILL_LAST_STANDING = """
|
||||
UPDATE matches SET win_reason = 'last_standing'
|
||||
WHERE status = 'finished'
|
||||
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
||||
AND (SELECT COUNT(*) FROM match_participants mp
|
||||
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
|
||||
AND (SELECT COUNT(*) FROM match_participants mp WHERE mp.match_id = matches.id) >= 2
|
||||
"""
|
||||
|
||||
|
||||
def _columns(insp, table: str) -> set[str]:
|
||||
return {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def _win_reason_check(insp) -> str | None:
|
||||
for ck in insp.get_check_constraints("matches"):
|
||||
if ck.get("name") == CK_WIN_REASON:
|
||||
return ck["sqltext"]
|
||||
return None
|
||||
|
||||
|
||||
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
|
||||
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
|
||||
|
||||
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
|
||||
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
|
||||
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
|
||||
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
|
||||
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
|
||||
raise RuntimeError(
|
||||
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
|
||||
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
|
||||
)
|
||||
with op.batch_alter_table("matches", recreate="always") as b:
|
||||
if drop_existing:
|
||||
b.drop_constraint(CK_WIN_REASON, type_="check")
|
||||
b.create_check_constraint(CK_WIN_REASON, sqltext)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
|
||||
if "nine_rounds_rule" not in _columns(insp, "groups"):
|
||||
with op.batch_alter_table("groups") as b:
|
||||
b.add_column(
|
||||
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
match_cols = _columns(insp, "matches")
|
||||
with op.batch_alter_table("matches") as b:
|
||||
if "end_round" not in match_cols:
|
||||
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
|
||||
if "nine_rounds_rule" not in match_cols:
|
||||
b.add_column(
|
||||
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
participant_cols = _columns(insp, "match_participants")
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
if "objectives" not in participant_cols:
|
||||
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
|
||||
if "worlds" not in participant_cols:
|
||||
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
|
||||
|
||||
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
|
||||
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
|
||||
check = _win_reason_check(inspect(bind))
|
||||
if check is not None and "last_standing" not in check:
|
||||
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
|
||||
|
||||
op.execute(BACKFILL_LAST_STANDING)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
|
||||
# записывали «по целям»).
|
||||
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
|
||||
check = _win_reason_check(inspect(bind))
|
||||
if check is not None and "last_standing" in check:
|
||||
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
|
||||
|
||||
insp = inspect(bind)
|
||||
participant_cols = _columns(insp, "match_participants")
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
for name in ("worlds", "objectives"):
|
||||
if name in participant_cols:
|
||||
b.drop_column(name)
|
||||
match_cols = _columns(insp, "matches")
|
||||
with op.batch_alter_table("matches") as b:
|
||||
for name in ("nine_rounds_rule", "end_round"):
|
||||
if name in match_cols:
|
||||
b.drop_column(name)
|
||||
if "nine_rounds_rule" in _columns(insp, "groups"):
|
||||
with op.batch_alter_table("groups") as b:
|
||||
b.drop_column("nine_rounds_rule")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Объявления администрации (#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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Слияние веток миграций: рейтинг (0014) и объявления (0015).
|
||||
|
||||
Объявления (#84) вышли в main раньше рейтинга, поэтому 0015 отходит от 0013, а не от 0014,
|
||||
и у графа миграций две ветки. Эта миграция сводит их в одну голову и сама ничего не меняет:
|
||||
прод, стоящий на 0015 (релиз без рейтинга), при переходе на эту версию получит 0014 и её;
|
||||
база на 0014 (dev) — 0015 и её.
|
||||
|
||||
Revision ID: 0016_merge_rating_announcements
|
||||
Revises: 0014_rating_inputs, 0015_announcements
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0016_merge_rating_announcements"
|
||||
down_revision: Union[str, Sequence[str], None] = ("0014_rating_inputs", "0015_announcements")
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Вход администратора под защитой от перебора (#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,9 +21,14 @@ def get_current_user(
|
||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||
except jwt.PyJWTError as exc: # noqa: F841
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or not user.is_active:
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
||||
raise AuthError("Сессия недействительна.")
|
||||
return user
|
||||
|
||||
|
||||
@@ -37,7 +42,11 @@ def get_current_admin(
|
||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||
except jwt.PyJWTError:
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or user.role != "admin" or not user.is_active:
|
||||
raise ForbiddenError("Доступ только для администратора.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
return user
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||
|
||||
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
||||
и dev-входом.
|
||||
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
|
||||
Telegram, /auth/login, /auth/register и dev-вход.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlmodel import Session
|
||||
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core import security
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import ForbiddenError
|
||||
from app.models import User
|
||||
from app.services import audit_service, user_service
|
||||
@@ -21,7 +22,7 @@ def establish_session(
|
||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||
if not user.is_active:
|
||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
|
||||
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type]
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
@@ -29,7 +30,7 @@ def establish_session(
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"provider": provider},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Вход игрока по логину (нику) и паролю.
|
||||
|
||||
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с 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 (test/prod = только TG).
|
||||
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
|
||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -9,7 +9,7 @@ from app.core.config import settings
|
||||
|
||||
|
||||
def enabled_methods() -> list[str]:
|
||||
methods = ["telegram"]
|
||||
methods = ["password", "telegram"]
|
||||
if settings.is_development:
|
||||
methods.append("stub")
|
||||
return methods
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
||||
Доступен во всех окружениях — наряду со входом по логину и паролю.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||
|
||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -11,6 +14,7 @@ from app.core.security import hash_password, verify_password
|
||||
from app.db.session import engine
|
||||
from app.models import User
|
||||
from app.seed.reference_data import seed_reference_data
|
||||
from app.services import user_service
|
||||
|
||||
|
||||
def _ensure_admin(session: Session) -> None:
|
||||
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
|
||||
|
||||
# Администратор уже существует.
|
||||
if not settings.is_development:
|
||||
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
||||
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
|
||||
# по себе ничего не делает — применить её можно только осознанно, командой
|
||||
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
|
||||
return
|
||||
|
||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
|
||||
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
||||
|
||||
|
||||
def reset_admin_password(session: Session) -> User:
|
||||
"""Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении.
|
||||
|
||||
Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт:
|
||||
это единственный штатный способ, и он требует доступа к серверу. set_password
|
||||
проверяет пароль и увеличивает token_version — все выданные админские сессии
|
||||
(в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем."""
|
||||
if not settings.admin_bootstrap_enabled:
|
||||
raise RuntimeError(
|
||||
"ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена."
|
||||
)
|
||||
password = (settings.admin_password or "").strip()
|
||||
if not password:
|
||||
raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.")
|
||||
admin = session.exec(select(User).where(User.role == "admin")).first()
|
||||
if admin is None:
|
||||
raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.")
|
||||
return user_service.set_password(session, admin, password)
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
with Session(engine) as session:
|
||||
seed_reference_data(session) # идемпотентно
|
||||
_ensure_admin(session)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
ap = argparse.ArgumentParser(prog="python -m app.bootstrap", description=__doc__)
|
||||
ap.add_argument(
|
||||
"--reset-admin-password",
|
||||
action="store_true",
|
||||
help="применить ADMIN_PASSWORD из .env к существующему админу и завершить его сессии",
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
if not args.reset_admin_password:
|
||||
bootstrap()
|
||||
return
|
||||
with Session(engine) as session:
|
||||
admin = reset_admin_password(session)
|
||||
print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; "
|
||||
"все его сессии завершены.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bootstrap()
|
||||
main()
|
||||
|
||||
+83
-19
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||
@@ -11,6 +12,15 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||
_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):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -20,20 +30,21 @@ class Settings(BaseSettings):
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# ── Главный переключатель окружения: development | test | production ───────
|
||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
||||
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
|
||||
# ── Главный переключатель окружения: development | production ─────────────
|
||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
|
||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Публикация локального окружения (dev/test) наружу через VPS-туннель.
|
||||
# Публикация локального dev-окружения наружу через VPS-туннель.
|
||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
||||
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
||||
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
|
||||
# от этого зависят Secure-cookie и проверка секретов при старте.
|
||||
local_public: str = "local"
|
||||
|
||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
||||
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
|
||||
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
|
||||
# на фронте — оно приходит туда в GET /api/auth/config (#68).
|
||||
app_tz_offset_hours: int = 3
|
||||
|
||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||
@@ -50,15 +61,15 @@ class Settings(BaseSettings):
|
||||
prod_achievements_dir: str = "/data/achievements"
|
||||
|
||||
# JWT / cookie
|
||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||
secret_key: str = _DEFAULT_SECRET_KEY
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||
jwt_admin_ttl_minutes: int = 60 * 8
|
||||
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
||||
cookie_domain: str | None = None
|
||||
|
||||
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
||||
# prod: только telegram) — отдельного переключателя провайдера нет.
|
||||
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
|
||||
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
|
||||
telegram_bot_token: str | None = None
|
||||
telegram_bot_username: str | None = None
|
||||
public_base_url: str | None = None
|
||||
@@ -66,7 +77,7 @@ class Settings(BaseSettings):
|
||||
# Бутстрап администратора
|
||||
admin_bootstrap_enabled: bool = True
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change-me-admin-password"
|
||||
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
||||
admin_nickname: str = "Администратор"
|
||||
|
||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||
@@ -84,17 +95,13 @@ class Settings(BaseSettings):
|
||||
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
||||
return self.app_env.lower() == "development"
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
return self.app_env.lower() == "test"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
|
||||
"""БД: dev — отдельный файл дева; prod — том контейнера (/data)."""
|
||||
return self.dev_database_url if self.is_development else self.prod_database_url
|
||||
|
||||
@property
|
||||
@@ -106,15 +113,72 @@ class Settings(BaseSettings):
|
||||
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
||||
|
||||
@property
|
||||
def cookie_secure(self) -> bool:
|
||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
||||
нативный dev на localhost по HTTP (development + local_public=local)."""
|
||||
def is_published(self) -> bool:
|
||||
"""Приложение доступно снаружи по домену: production или dev, выставленный через
|
||||
VPS-туннель. Не опубликован только нативный dev на localhost
|
||||
(development + local_public=local)."""
|
||||
return not (self.is_development and self.local_public.lower() == "local")
|
||||
|
||||
@property
|
||||
def cookie_secure(self) -> bool:
|
||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
|
||||
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
|
||||
return self.is_published
|
||||
|
||||
@property
|
||||
def cookie_domain_value(self) -> str | None:
|
||||
return self.cookie_domain or None
|
||||
|
||||
@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
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -136,5 +136,41 @@ class InvalidCredentialsError(AuthError):
|
||||
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:
|
||||
return exc.to_response()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||
|
||||
Счётчики живут в памяти процесса — как и 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()
|
||||
@@ -6,9 +6,10 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Response
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.token_revocation import revoked_tokens
|
||||
|
||||
USER_COOKIE = "fs_session"
|
||||
ADMIN_COOKIE = "fs_admin"
|
||||
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
|
||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
||||
def create_token(
|
||||
subject: str | int,
|
||||
audience: str,
|
||||
ttl_minutes: int,
|
||||
provider: str = "",
|
||||
token_version: int = 0,
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
@@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider:
|
||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||
"jti": secrets.token_hex(8),
|
||||
"provider": provider,
|
||||
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
||||
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
||||
"ver": token_version,
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
@@ -65,12 +75,19 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
def _csrf_max_age() -> int:
|
||||
"""Токен один на обе сессии, поэтому живёт не меньше самой долгой из них: иначе вход
|
||||
в админку (8 ч) перезаписывал токен игрока (7 дней), и после его истечения все мутации
|
||||
игрока падали с CSRF_FAILED. Без сессии токен бесполезен — лишний срок безопасен."""
|
||||
return max(settings.jwt_user_ttl_minutes, settings.jwt_admin_ttl_minutes) * 60
|
||||
|
||||
|
||||
def _set_csrf_cookie(response: Response) -> str:
|
||||
csrf = generate_csrf_token()
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE,
|
||||
value=csrf,
|
||||
max_age=max_age,
|
||||
max_age=_csrf_max_age(),
|
||||
httponly=False, # должен читаться JS, чтобы продублировать в заголовок
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
@@ -80,9 +97,17 @@ def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
return csrf
|
||||
|
||||
|
||||
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
|
||||
"""Готовый заголовок Set-Cookie со свежим токеном — для ASGI-middleware, где объекта
|
||||
Response нет. Строится тем же _set_csrf_cookie, чтобы атрибуты не разъехались."""
|
||||
carrier = Response()
|
||||
_set_csrf_cookie(carrier)
|
||||
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:
|
||||
ttl = settings.jwt_user_ttl_minutes
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
|
||||
response.set_cookie(
|
||||
key=USER_COOKIE,
|
||||
value=token,
|
||||
@@ -93,12 +118,12 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
path=_USER_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
_set_csrf_cookie(response)
|
||||
|
||||
|
||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None:
|
||||
ttl = settings.jwt_admin_ttl_minutes
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
|
||||
response.set_cookie(
|
||||
key=ADMIN_COOKIE,
|
||||
value=token,
|
||||
@@ -109,7 +134,7 @@ def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
path=_ADMIN_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
_set_csrf_cookie(response)
|
||||
|
||||
|
||||
def clear_user_session(response: Response) -> None:
|
||||
@@ -118,3 +143,38 @@ def clear_user_session(response: Response) -> None:
|
||||
|
||||
def clear_admin_session(response: Response) -> None:
|
||||
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:
|
||||
"""IP клиента для журнала аудита.
|
||||
|
||||
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
|
||||
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
|
||||
return request.client.host if request.client else None
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Точечный отзыв отдельных 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()
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
||||
|
||||
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
|
||||
где БД поднимается из чистого состояния.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.db.session import engine
|
||||
from app.seed.reference_data import seed_reference_data
|
||||
from sqlmodel import Session
|
||||
|
||||
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
|
||||
import app.models # noqa: F401
|
||||
|
||||
|
||||
def create_db_and_seed() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
seed_reference_data(session)
|
||||
+65
-16
@@ -17,6 +17,7 @@ from app.core.errors import AppError, app_error_handler
|
||||
from app.routers import (
|
||||
achievements,
|
||||
admin,
|
||||
announcements,
|
||||
auth,
|
||||
events,
|
||||
groups,
|
||||
@@ -34,12 +35,35 @@ _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.p
|
||||
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _with_fresh_csrf_cookie(send): # noqa: ANN001, ANN202
|
||||
"""Дописывает свежий csrf_token в заголовки ответа. Трогает только
|
||||
http.response.start: тело (в т.ч. SSE-поток) проходит насквозь, чанк за чанком."""
|
||||
|
||||
async def wrapped(message): # noqa: ANN001
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
already_set = any(
|
||||
k.lower() == b"set-cookie" and v.startswith(security.CSRF_COOKIE.encode() + b"=")
|
||||
for k, v in headers
|
||||
)
|
||||
if not already_set:
|
||||
headers.append(security.fresh_csrf_set_cookie())
|
||||
message = {**message, "headers": headers}
|
||||
await send(message)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class CSRFMiddleware:
|
||||
"""Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем
|
||||
совпадения заголовка X-CSRF-Token и cookie csrf_token.
|
||||
|
||||
Если сессия есть, а csrf_token в запросе нет (cookie истекла или её стёрли), любой ответ
|
||||
на /api — включая отказ ниже — перевыдаёт токен. Иначе состояние не лечилось: токен
|
||||
выдаётся только при входе, а войти и выйти мешала эта же проверка.
|
||||
|
||||
Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE
|
||||
(/api/events). Чистый ASGI пропускает стримы насквозь, вмешиваясь только при отказе CSRF.
|
||||
(/api/events). Чистый ASGI пропускает стримы насквозь.
|
||||
"""
|
||||
|
||||
def __init__(self, app) -> None: # noqa: ANN001
|
||||
@@ -48,13 +72,15 @@ class CSRFMiddleware:
|
||||
async def __call__(self, scope, receive, send): # noqa: ANN001
|
||||
if scope["type"] == "http":
|
||||
request = Request(scope)
|
||||
if request.method in _UNSAFE_METHODS and request.url.path.startswith("/api"):
|
||||
if request.url.path.startswith("/api"):
|
||||
has_session = (
|
||||
security.USER_COOKIE in request.cookies
|
||||
or security.ADMIN_COOKIE in request.cookies
|
||||
)
|
||||
if has_session:
|
||||
cookie_token = request.cookies.get(security.CSRF_COOKIE)
|
||||
cookie_token = request.cookies.get(security.CSRF_COOKIE)
|
||||
if has_session and not cookie_token:
|
||||
send = _with_fresh_csrf_cookie(send)
|
||||
if has_session and request.method in _UNSAFE_METHODS:
|
||||
header_token = request.headers.get(security.CSRF_HEADER)
|
||||
if not cookie_token or cookie_token != header_token:
|
||||
response = JSONResponse(
|
||||
@@ -72,12 +98,12 @@ class CSRFMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим протухшие уведомления (>72ч)
|
||||
_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления
|
||||
|
||||
|
||||
async def _notifications_purge_loop() -> None:
|
||||
"""Фоновая чистка протухших уведомлений (single-worker безопасно). Чтобы удалялись
|
||||
«отовсюду» даже у неактивных пользователей (помимо очистки при чтении списка)."""
|
||||
"""Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы
|
||||
удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении)."""
|
||||
import asyncio
|
||||
|
||||
from app.db.session import Session, engine
|
||||
@@ -106,8 +132,18 @@ async def _lifespan(_app: FastAPI):
|
||||
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
|
||||
if settings.is_development and settings.is_published:
|
||||
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
|
||||
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
|
||||
logging.getLogger("fs").warning(
|
||||
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
|
||||
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
|
||||
"и Swagger. Не держите в dev-базе копию прод-данных.",
|
||||
settings.local_public,
|
||||
)
|
||||
|
||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||
try:
|
||||
from app.bootstrap import bootstrap
|
||||
@@ -126,17 +162,21 @@ async def _lifespan(_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(
|
||||
title="Forbidden Stars API",
|
||||
version="0.1.0",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json" if docs_enabled else None,
|
||||
docs_url="/api/docs" if docs_enabled else None,
|
||||
redoc_url="/api/redoc" if docs_enabled else None,
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
|
||||
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||
if settings.is_development and settings.cors_origins_list:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -152,13 +192,17 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
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(
|
||||
status_code=422,
|
||||
content={
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Ошибка валидации запроса.",
|
||||
"details": exc.errors(),
|
||||
"details": details,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -166,12 +210,13 @@ def create_app() -> FastAPI:
|
||||
# API-роутеры под /api.
|
||||
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
||||
matches.router, reference.router, stats.router, achievements.router,
|
||||
events.router, notifications.router, admin.router]
|
||||
events.router, notifications.router, announcements.router,
|
||||
admin.router]
|
||||
for r in api_routers:
|
||||
app.include_router(r, prefix="/api")
|
||||
|
||||
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
||||
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
|
||||
# и только если код физически есть (в прод-образе dev_*-файлы исключены
|
||||
# .dockerignore, импорт просто не выполнится).
|
||||
if settings.is_development:
|
||||
for mod_name in ("dev_auth", "dev_admin"):
|
||||
@@ -208,9 +253,13 @@ def _mount_spa(app: FastAPI) -> None:
|
||||
full_path
|
||||
and _STATIC_DIR in candidate.parents
|
||||
and candidate.is_file()
|
||||
and candidate.name != "index.html"
|
||||
):
|
||||
# Хэшированные ассеты Vite — обычное кэширование (имя меняется при сборке).
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(index_file)
|
||||
# index.html (корень и SPA-маршруты) — НЕ кэшируем: иначе браузер отдаёт старый
|
||||
# документ из кэша и при недоступности хоста не доходит до Caddy с заглушкой.
|
||||
return FileResponse(index_file, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
+170
-31
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
@@ -23,9 +23,9 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutil import utcnow
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ─── Справочники: дополнения и фракции ───────────────────────────────────────
|
||||
@@ -38,7 +38,7 @@ class Expansion(SQLModel, table=True):
|
||||
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
sort_order: int = Field(default=0, nullable=False)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class Faction(SQLModel, table=True):
|
||||
@@ -56,7 +56,7 @@ class Faction(SQLModel, table=True):
|
||||
)
|
||||
)
|
||||
sort_order: int = Field(default=0, nullable=False)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Пользователи и идентичности ─────────────────────────────────────────────
|
||||
@@ -73,6 +73,10 @@ class User(SQLModel, table=True):
|
||||
"role <> 'admin' OR password_hash IS NOT NULL",
|
||||
name="ck_users_admin_has_password",
|
||||
),
|
||||
CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"),
|
||||
CheckConstraint(
|
||||
"history_detail IN ('compact','full')", name="ck_users_history_detail"
|
||||
),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
@@ -89,6 +93,13 @@ class User(SQLModel, table=True):
|
||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||
)
|
||||
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(
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
@@ -97,14 +108,34 @@ class User(SQLModel, table=True):
|
||||
index=True,
|
||||
)
|
||||
)
|
||||
# Витрина истории партий в профиле: что показывать (все / только лучшая по очкам)
|
||||
# и насколько подробно. Действует и для гостей профиля, не только для владельца.
|
||||
history_mode: str = Field(
|
||||
default="all",
|
||||
sa_column=Column(String(8), nullable=False, server_default="all"),
|
||||
)
|
||||
history_detail: str = Field(
|
||||
default="compact",
|
||||
sa_column=Column(String(8), nullable=False, server_default="compact"),
|
||||
)
|
||||
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
|
||||
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
|
||||
favorite_faction_id: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
ForeignKey("factions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
||||
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
||||
# (выдача ачивок игрокам — следующий этап).
|
||||
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -126,7 +157,7 @@ class UserAchievement(SQLModel, table=True):
|
||||
)
|
||||
)
|
||||
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
earned_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class AuthIdentity(SQLModel, table=True):
|
||||
@@ -143,7 +174,7 @@ class AuthIdentity(SQLModel, table=True):
|
||||
)
|
||||
provider: str = Field(sa_column=Column(String(16), nullable=False))
|
||||
external_id: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||
@@ -158,12 +189,18 @@ class Group(SQLModel, table=True):
|
||||
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
|
||||
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
|
||||
nine_rounds_rule: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
class GroupMember(SQLModel, table=True):
|
||||
@@ -185,7 +222,7 @@ class GroupMember(SQLModel, table=True):
|
||||
)
|
||||
)
|
||||
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
|
||||
joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
joined_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class GroupInvitation(SQLModel, table=True):
|
||||
@@ -213,7 +250,7 @@ class GroupInvitation(SQLModel, table=True):
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class GroupExpansion(SQLModel, table=True):
|
||||
@@ -233,7 +270,7 @@ class GroupExpansion(SQLModel, table=True):
|
||||
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Партии и участники ──────────────────────────────────────────────────────
|
||||
@@ -244,7 +281,8 @@ class Match(SQLModel, table=True):
|
||||
Index("ix_matches_group_played", "group_id", "played_at"),
|
||||
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
||||
CheckConstraint(
|
||||
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
|
||||
"win_reason IS NULL OR win_reason IN "
|
||||
"('objectives','worlds','plastic','resources','last_standing')",
|
||||
name="ck_match_win_reason",
|
||||
),
|
||||
)
|
||||
@@ -264,6 +302,14 @@ class Match(SQLModel, table=True):
|
||||
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
|
||||
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
|
||||
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
|
||||
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
|
||||
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
# Снимок Group.nine_rounds_rule на момент старта партии.
|
||||
nine_rounds_rule: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="0"),
|
||||
)
|
||||
player_count: int = Field(sa_column=Column(Integer, nullable=False))
|
||||
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_by: int = Field(
|
||||
@@ -271,10 +317,10 @@ class Match(SQLModel, table=True):
|
||||
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -309,7 +355,11 @@ class MatchParticipant(SQLModel, table=True):
|
||||
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
# Итог партии для рейтинга (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)
|
||||
|
||||
|
||||
class MatchAttachment(SQLModel, table=True):
|
||||
@@ -335,16 +385,48 @@ class MatchAttachment(SQLModel, table=True):
|
||||
storage_path: str = Field(sa_column=Column(String(255), nullable=False))
|
||||
mime_type: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
size_bytes: int = Field(sa_column=Column(Integer, nullable=False))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
|
||||
class MatchFinishDraft(SQLModel, table=True):
|
||||
"""Общий черновик формы завершения партии: раскладка мест, ничьи, выбывшие,
|
||||
комментарии и причина победы, пока партию не завершили.
|
||||
|
||||
Отдельная таблица, а не колонки в matches, намеренно: запись в строку партии
|
||||
дёргает onupdate у matches.updated_at, а это версия для оптимистичной блокировки —
|
||||
«Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.
|
||||
Живёт только пока партия идёт: finish_match удаляет строку, удаление партии
|
||||
уносит её каскадом."""
|
||||
|
||||
__tablename__ = "match_finish_drafts"
|
||||
|
||||
match_id: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("matches.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
)
|
||||
data: dict = Field(sa_column=Column(JSON, nullable=False))
|
||||
updated_by: int | None = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# ─── Уведомления ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Notification(SQLModel, table=True):
|
||||
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
|
||||
|
||||
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
|
||||
фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка."""
|
||||
фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя),
|
||||
прочитанные чистятся спустя 72 часа — фоном и при чтении списка."""
|
||||
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
@@ -362,7 +444,70 @@ class Notification(SQLModel, table=True):
|
||||
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
|
||||
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=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)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
@@ -386,12 +531,6 @@ class AuditLog(SQLModel, table=True):
|
||||
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
ip: str | None = Field(sa_column=Column(String(45), nullable=True))
|
||||
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
|
||||
# class Attachment(SQLModel, table=True):
|
||||
# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL),
|
||||
# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type,
|
||||
# size_bytes, created_at
|
||||
# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь.
|
||||
|
||||
+204
-63
@@ -5,17 +5,20 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.admin_login import login_admin
|
||||
from app.auth.deps import get_current_admin
|
||||
from app.core import security
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import InvalidCredentialsError, NotFoundError
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.routers.matches import attachment_read, build_match_read
|
||||
from app.routers.matches import attachment_read, build_match_read, participant_inputs
|
||||
from app.schemas import api as s
|
||||
from app.services import (
|
||||
achievement_service,
|
||||
admin_service,
|
||||
announcement_service,
|
||||
attachment_service,
|
||||
audit_service,
|
||||
faction_service,
|
||||
@@ -25,8 +28,6 @@ from app.services import (
|
||||
)
|
||||
|
||||
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
||||
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
|
||||
from app.services.match_service import ParticipantInput
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -40,15 +41,30 @@ def admin_login(
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.AdminMe:
|
||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
||||
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
||||
try:
|
||||
admin = login_admin(session, body.username, body.password, client_ip(request))
|
||||
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(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="login",
|
||||
entity_type="admin",
|
||||
entity_id=admin.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -56,7 +72,8 @@ def admin_login(
|
||||
|
||||
|
||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
||||
def admin_logout(response: Response) -> s.OkResponse:
|
||||
def admin_logout(request: Request, response: Response) -> s.OkResponse:
|
||||
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
|
||||
security.clear_admin_session(response)
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -68,24 +85,26 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
||||
|
||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||
|
||||
def _admin_user_read(u: User) -> s.AdminUserRead:
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=iso_utc(u.created_at),
|
||||
has_password=u.password_hash is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||
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]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=iso_utc(u.created_at),
|
||||
)
|
||||
for u in admin_service.list_users(session, query)
|
||||
]
|
||||
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
||||
@@ -104,22 +123,37 @@ def update_user(
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload=body.model_dump(exclude_none=True),
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=u.created_at.isoformat(),
|
||||
return _admin_user_read(u)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
||||
def set_user_password(
|
||||
user_id: int,
|
||||
body: s.AdminPasswordSet,
|
||||
request: Request,
|
||||
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 возможность, вынесена в
|
||||
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
|
||||
# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только
|
||||
# отключается (PATCH is_active), удалять нельзя.
|
||||
|
||||
|
||||
@@ -148,7 +182,7 @@ def delete_group(
|
||||
admin_service.delete_group(session, group_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
@@ -209,19 +243,6 @@ def update_match(
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
@@ -230,7 +251,9 @@ def update_match(
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
end_round=body.end_round,
|
||||
end_round_set=("end_round" in body.model_fields_set),
|
||||
participants=participant_inputs(body),
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
audit_service.record(
|
||||
@@ -239,7 +262,7 @@ def update_match(
|
||||
action="update",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
@@ -277,7 +300,7 @@ def rename_faction(
|
||||
entity_type="faction",
|
||||
entity_id=faction_id,
|
||||
payload={"name_ru": f.name_ru},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.FactionRead(
|
||||
@@ -292,14 +315,15 @@ def delete_match(
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
||||
match = match_service.get_match(session, match_id)
|
||||
group_id, finished = match.group_id, match.status == "finished" # для уведомления
|
||||
admin_service.delete_match(session, match_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id, group_id)
|
||||
notify.match_removed(session, match_id, group_id, finished=finished)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -326,12 +350,9 @@ def admin_add_attachment(
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AttachmentRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1)
|
||||
if len(content) > _ATTACHMENT_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 10 МБ).")
|
||||
ext = user_service.sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
content, ext = user_service.read_capped_image(
|
||||
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
|
||||
)
|
||||
att = attachment_service.add_photo(
|
||||
session, match, admin, content, ext, user_service.avatar_media_type(ext)
|
||||
)
|
||||
@@ -389,7 +410,7 @@ def create_achievement(
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="create", entity_type="achievement",
|
||||
payload={"slug": ach["slug"], "name": ach["name"]},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return ach
|
||||
@@ -408,7 +429,7 @@ def update_achievement(
|
||||
)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="update", entity_type="achievement",
|
||||
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
||||
payload={"slug": slug}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return ach
|
||||
@@ -420,10 +441,9 @@ def upload_achievement_icon(
|
||||
file: UploadFile = File(...),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1)
|
||||
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
||||
ext = achievement_service.validate_icon(content)
|
||||
content, ext = user_service.read_capped_image(
|
||||
file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
||||
)
|
||||
return achievement_service.set_icon(slug, content, ext)
|
||||
|
||||
|
||||
@@ -437,12 +457,133 @@ def delete_achievement(
|
||||
achievement_service.delete(slug)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="achievement",
|
||||
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
||||
payload={"slug": slug}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Объявления администрации для игрока: что показать сейчас и «Понятно».
|
||||
|
||||
Появление нового объявления у открытой вкладки обеспечивает 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
|
||||
|
||||
@@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlmodel import 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.telegram import TelegramProvider
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.core.errors import TelegramNicknameRequiredError
|
||||
from app.core.security import client_ip
|
||||
from app.db.session import get_session
|
||||
from app.routers.users import build_me
|
||||
from app.schemas import api as s
|
||||
from app.services import user_service
|
||||
from app.services import audit_service, user_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -26,9 +28,47 @@ def auth_config() -> s.AuthConfig:
|
||||
return s.AuthConfig(
|
||||
methods=enabled_methods(),
|
||||
telegram_bot_username=settings.telegram_bot_username,
|
||||
tz_offset_hours=settings.app_tz_offset_hours,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
def telegram_login(
|
||||
body: s.TelegramAuthPayload,
|
||||
@@ -78,6 +118,9 @@ def telegram_register(
|
||||
|
||||
|
||||
@router.post("/logout", response_model=s.OkResponse)
|
||||
def logout(response: Response) -> s.OkResponse:
|
||||
def logout(request: Request, 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)
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||||
|
||||
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
|
||||
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
|
||||
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||||
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||||
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
|
||||
возможность удаления не попадает в прод — там аккаунт можно только
|
||||
отключить (PATCH is_active).
|
||||
|
||||
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.deps import get_current_admin
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.db.session import get_session
|
||||
from app.models import Group, Match, MatchParticipant, User
|
||||
@@ -63,7 +64,7 @@ def delete_user_hard(
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload={"hard": True, "nickname": nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
||||
|
||||
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
||||
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
|
||||
входа по логину остаётся только на деве.
|
||||
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
|
||||
без пароля остаётся только на деве (по логину и паролю входят везде).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.security import client_ip
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
@@ -17,6 +18,7 @@ from app.services import (
|
||||
notification_service,
|
||||
notify,
|
||||
stats_service,
|
||||
user_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
@@ -31,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
|
||||
owner_id=group.owner_id,
|
||||
my_role=member.role,
|
||||
expansion_ids=group_service.group_expansion_ids(session, group_id),
|
||||
nine_rounds_rule=group.nine_rounds_rule,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +63,7 @@ def create_group(
|
||||
entity_type="group",
|
||||
entity_id=group.id,
|
||||
payload={"name": group.name},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -77,15 +80,19 @@ def get_group(
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=s.GroupDetail)
|
||||
def rename_group(
|
||||
def update_group(
|
||||
group_id: int,
|
||||
body: s.GroupRename,
|
||||
body: s.GroupUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.GroupDetail:
|
||||
"""Название и домашние правила группы; меняются только переданные поля."""
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
group_service.rename_group(session, group, body.name)
|
||||
if body.name is not None:
|
||||
group_service.rename_group(session, group, body.name)
|
||||
if body.nine_rounds_rule is not None:
|
||||
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
|
||||
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@@ -127,7 +134,12 @@ def list_members(
|
||||
) -> list[s.MemberRead]:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
return [
|
||||
s.MemberRead(user_id=u.id, nickname=u.nickname, role=m.role)
|
||||
s.MemberRead(
|
||||
user_id=u.id,
|
||||
nickname=u.nickname,
|
||||
role=m.role,
|
||||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||
)
|
||||
for m, u in membership_service.list_members(session, group_id)
|
||||
]
|
||||
|
||||
@@ -151,7 +163,7 @@ def invite_member(
|
||||
entity_type="group_invitation",
|
||||
entity_id=group_id,
|
||||
payload={"invited_user_id": invited.id, "nickname": invited.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -195,10 +207,13 @@ def change_member_role(
|
||||
group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
member = membership_service.change_role(session, group, user_id, body.role)
|
||||
from app.services.user_service import get_user
|
||||
|
||||
u = get_user(session, user_id)
|
||||
return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role)
|
||||
u = user_service.get_user(session, user_id)
|
||||
return s.MemberRead(
|
||||
user_id=u.id,
|
||||
nickname=u.nickname,
|
||||
role=member.role,
|
||||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||
)
|
||||
|
||||
|
||||
# ─── Партии и статистика группы ──────────────────────────────────────────────
|
||||
|
||||
@@ -6,7 +6,8 @@ from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.errors import ConflictError, NoGroupError, NotFoundError, ValidationError
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import ConflictError, NoGroupError, NotFoundError
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import Match, MatchAttachment, User
|
||||
@@ -21,10 +22,29 @@ from app.services import (
|
||||
user_service,
|
||||
)
|
||||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||
from app.services.scoring import max_rounds
|
||||
|
||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
||||
|
||||
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
|
||||
|
||||
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:
|
||||
@@ -56,9 +76,22 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
objectives=p.objectives,
|
||||
worlds=p.worlds,
|
||||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||||
)
|
||||
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
|
||||
]
|
||||
draft_row = match_service.get_finish_draft(session, match.id) # type: ignore[arg-type]
|
||||
draft = None
|
||||
if draft_row is not None:
|
||||
author = session.get(User, draft_row.updated_by) if draft_row.updated_by else None
|
||||
draft = s.MatchFinishDraftRead(
|
||||
data=s.MatchFinishDraftData(**draft_row.data),
|
||||
updated_by=draft_row.updated_by,
|
||||
updated_by_nickname=author.nickname if author else None,
|
||||
updated_at=iso_utc(draft_row.updated_at),
|
||||
)
|
||||
return s.MatchRead(
|
||||
id=match.id, # type: ignore[arg-type]
|
||||
group_id=match.group_id,
|
||||
@@ -68,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
finished_at=iso_utc(match.finished_at),
|
||||
duration_minutes=match.duration_minutes,
|
||||
win_reason=match.win_reason, # type: ignore[arg-type]
|
||||
end_round=match.end_round,
|
||||
nine_rounds_rule=match.nine_rounds_rule,
|
||||
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
|
||||
player_count=match.player_count,
|
||||
overall_comment=match.overall_comment,
|
||||
created_by=match.created_by,
|
||||
@@ -78,6 +114,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
attachment_read(a, f"/api/matches/{match.id}")
|
||||
for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type]
|
||||
],
|
||||
finish_draft=draft,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,7 +155,7 @@ def start_match(
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -145,6 +182,8 @@ def finish_match(
|
||||
eliminated=p.eliminated,
|
||||
comment=p.comment,
|
||||
faction_id=p.faction_id,
|
||||
objectives=p.objectives,
|
||||
worlds=p.worlds,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
@@ -153,6 +192,7 @@ def finish_match(
|
||||
match,
|
||||
finish=finish,
|
||||
win_reason=body.win_reason,
|
||||
end_round=body.end_round,
|
||||
overall_comment=body.overall_comment,
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
expected_version=body.expected_version,
|
||||
@@ -164,7 +204,7 @@ def finish_match(
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -194,19 +234,6 @@ def update_match(
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
@@ -215,7 +242,9 @@ def update_match(
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
end_round=body.end_round,
|
||||
end_round_set=("end_round" in body.model_fields_set),
|
||||
participants=participant_inputs(body),
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
audit_service.record(
|
||||
@@ -224,7 +253,7 @@ def update_match(
|
||||
action="update",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -232,6 +261,26 @@ def update_match(
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
|
||||
@router.put("/{match_id}/finish-draft", response_model=s.MatchRead)
|
||||
def save_finish_draft(
|
||||
match_id: int,
|
||||
body: s.MatchFinishDraftData,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MatchRead:
|
||||
"""Общий черновик формы завершения: то, что видят все, кто заполняет партию.
|
||||
|
||||
Права те же, что у самой формы. Версию партии запись черновика не двигает —
|
||||
иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку."""
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
match_service.save_finish_draft(session, match, user, body.model_dump())
|
||||
notify.match_draft_changed(session, match, actor_id=user.id) # type: ignore[arg-type]
|
||||
return build_match_read(
|
||||
session, match, can_modify=match_service.can_modify(session, match, user)
|
||||
)
|
||||
|
||||
# ─── Медиа партии (фото) ──────────────────────────────────────────────────────
|
||||
|
||||
def _assert_can_attach(session: Session, match: Match, user: User) -> None:
|
||||
@@ -249,12 +298,9 @@ def add_attachment(
|
||||
) -> s.AttachmentRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
_assert_can_attach(session, match, user)
|
||||
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1)
|
||||
if len(content) > _ATTACHMENT_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 10 МБ).")
|
||||
ext = user_service.sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
content, ext = user_service.read_capped_image(
|
||||
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
|
||||
)
|
||||
att = attachment_service.add_photo(
|
||||
session, match, user, content, ext, user_service.avatar_media_type(ext)
|
||||
)
|
||||
@@ -306,6 +352,7 @@ def delete_match(
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
match_id_val = match.id
|
||||
group_id_val = match.group_id
|
||||
finished = match.status == "finished" # после удаления статус уже не прочитать
|
||||
match_service.delete_match(session, match, expected_version=expected_version)
|
||||
audit_service.record(
|
||||
session,
|
||||
@@ -314,9 +361,9 @@ def delete_match(
|
||||
entity_type="match",
|
||||
entity_id=match_id_val,
|
||||
payload={"group_id": group_id_val},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type]
|
||||
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Уведомления игрока: список (pull) и отметка прочитанными.
|
||||
|
||||
Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент
|
||||
перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном)."""
|
||||
перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся
|
||||
спустя 72 часа (при чтении и фоном)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
+125
-12
@@ -1,12 +1,15 @@
|
||||
"""Роутер текущего пользователя."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.auth.password import check_current_password
|
||||
from app.auth.telegram import TelegramProvider
|
||||
from app.core.security import client_ip, set_user_session
|
||||
from app.core.errors import NotFoundError
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
@@ -31,7 +34,11 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
||||
active_group_id=user.active_group_id,
|
||||
bio=user.bio,
|
||||
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||
favorite_faction_id=user.favorite_faction_id,
|
||||
history_mode=user.history_mode,
|
||||
history_detail=user.history_detail,
|
||||
groups=groups,
|
||||
has_password=user.password_hash is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +65,7 @@ def update_me(
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"nickname": user.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -71,6 +78,9 @@ def update_me(
|
||||
active_group_id=user.active_group_id,
|
||||
bio=user.bio,
|
||||
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||
favorite_faction_id=user.favorite_faction_id,
|
||||
history_mode=user.history_mode,
|
||||
history_detail=user.history_detail,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,15 +91,82 @@ def update_my_profile(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
user_service.update_bio(session, user, body.bio)
|
||||
# Обновляем только реально переданные поля: «О себе» и любимая фракция правятся
|
||||
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
|
||||
changed = body.model_dump(exclude_unset=True)
|
||||
if "bio" in changed:
|
||||
user_service.update_bio(session, user, changed["bio"])
|
||||
if "favorite_faction_id" in changed:
|
||||
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
|
||||
if "history_mode" in changed or "history_detail" in changed:
|
||||
user_service.update_history_prefs(
|
||||
session,
|
||||
user,
|
||||
mode=changed.get("history_mode"),
|
||||
detail=changed.get("history_detail"),
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="update",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"bio": True},
|
||||
ip=request.client.host if request.client else None,
|
||||
payload={key: True for key in changed},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
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)
|
||||
@@ -101,12 +178,9 @@ def upload_my_avatar(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
content = file.file.read(_AVATAR_MAX_BYTES + 1)
|
||||
if len(content) > _AVATAR_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
||||
ext = user_service.sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
content, ext = user_service.read_capped_image(
|
||||
file, _AVATAR_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
||||
)
|
||||
user_service.set_avatar(session, user, content, ext)
|
||||
return build_me(session, user)
|
||||
|
||||
@@ -147,6 +221,26 @@ def get_user_profile(
|
||||
return user_service.public_profile(session, user_id)
|
||||
|
||||
|
||||
@router.get("/{user_id}/matches", response_model=s.MatchHistory)
|
||||
def user_matches(
|
||||
user_id: int,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""История партий игрока. Режим — витрина владельца профиля: её видят и гости."""
|
||||
owner = user_service.get_user(session, user_id)
|
||||
data = stats_service.user_match_list(
|
||||
session,
|
||||
user_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
best_only=owner.history_mode == "best",
|
||||
)
|
||||
return {**data, "mode": owner.history_mode, "detail": owner.history_detail}
|
||||
|
||||
|
||||
@router.get("/me/stats", response_model=s.ProfileStats)
|
||||
def my_stats(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -173,3 +267,22 @@ def nickname_available(
|
||||
) -> s.NicknameAvailable:
|
||||
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
|
||||
return s.NicknameAvailable(available=available)
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[s.UserSuggestion])
|
||||
def search_users(
|
||||
q: str = Query("", max_length=64),
|
||||
limit: int = Query(3, ge=1, le=10),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[s.UserSuggestion]:
|
||||
"""Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе."""
|
||||
users = user_service.search_users(session, q, limit=limit)
|
||||
return [
|
||||
s.UserSuggestion(
|
||||
user_id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
+198
-9
@@ -1,20 +1,52 @@
|
||||
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
||||
# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
|
||||
# тогда, когда невыбывший участник один (match_service._check_last_standing).
|
||||
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
|
||||
|
||||
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
|
||||
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
|
||||
EndRound = Annotated[int, Field(ge=1, le=9)]
|
||||
Count = Annotated[int, Field(ge=0, le=99)]
|
||||
|
||||
|
||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
||||
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||
methods: list[str] = []
|
||||
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):
|
||||
@@ -90,10 +122,18 @@ class UserRead(BaseModel):
|
||||
active_group_id: int | None = None
|
||||
bio: str | None = None
|
||||
avatar_url: str | None = None
|
||||
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
|
||||
favorite_faction_id: int | None = None
|
||||
# Витрина истории партий в профиле.
|
||||
history_mode: str = "all"
|
||||
history_detail: str = "compact"
|
||||
|
||||
|
||||
class MeRead(UserRead):
|
||||
groups: list[GroupBrief] = []
|
||||
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
|
||||
# фронт не пускает дальше окна установки пароля.
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class NicknameUpdate(BaseModel):
|
||||
@@ -101,7 +141,12 @@ class NicknameUpdate(BaseModel):
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
# Оба поля необязательны и обновляются, только если реально переданы
|
||||
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
|
||||
bio: str | None = None
|
||||
favorite_faction_id: int | None = None
|
||||
history_mode: str | None = None
|
||||
history_detail: str | None = None
|
||||
|
||||
|
||||
class ActiveGroupUpdate(BaseModel):
|
||||
@@ -112,6 +157,14 @@ class NicknameAvailable(BaseModel):
|
||||
available: bool
|
||||
|
||||
|
||||
class UserSuggestion(BaseModel):
|
||||
"""Подсказка автокомплита по нику (приглашение в группу и т.п.)."""
|
||||
|
||||
user_id: int
|
||||
nickname: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
@@ -119,8 +172,10 @@ class GroupCreate(BaseModel):
|
||||
expansion_ids: list[int] = []
|
||||
|
||||
|
||||
class GroupRename(BaseModel):
|
||||
name: str
|
||||
class GroupUpdate(BaseModel):
|
||||
# Частичная правка: переданные поля меняются, остальные остаются как есть.
|
||||
name: str | None = None
|
||||
nine_rounds_rule: bool | None = None
|
||||
|
||||
|
||||
class GroupExpansionsUpdate(BaseModel):
|
||||
@@ -133,12 +188,15 @@ class GroupDetail(BaseModel):
|
||||
owner_id: int
|
||||
my_role: str
|
||||
expansion_ids: list[int] = []
|
||||
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
|
||||
nine_rounds_rule: bool = False
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
user_id: int
|
||||
nickname: str
|
||||
role: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
@@ -178,6 +236,23 @@ class NotificationMarkRead(BaseModel):
|
||||
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):
|
||||
@@ -208,17 +283,20 @@ class MatchFinishParticipant(BaseModel):
|
||||
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
|
||||
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
|
||||
|
||||
|
||||
class MatchFinish(BaseModel):
|
||||
participants: list[MatchFinishParticipant]
|
||||
win_reason: WinReason
|
||||
end_round: EndRound | None = None # раунд, в котором партия закончилась
|
||||
overall_comment: str | None = None
|
||||
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
|
||||
expected_version: str | None = None
|
||||
|
||||
|
||||
# Полный участник (правка завершённой партии админом).
|
||||
# Полный участник (правка результатов завершённой партии).
|
||||
class ParticipantInput(BaseModel):
|
||||
user_id: int
|
||||
faction_id: int
|
||||
@@ -226,12 +304,15 @@ class ParticipantInput(BaseModel):
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
objectives: Count | None = None
|
||||
worlds: Count | None = None
|
||||
|
||||
|
||||
class MatchUpdate(BaseModel):
|
||||
played_at: date | None = None
|
||||
overall_comment: str | None = None
|
||||
win_reason: WinReason | None = None
|
||||
end_round: EndRound | None = None
|
||||
participants: list[ParticipantInput] | None = None
|
||||
expected_version: str | None = None # оптимистичная блокировка
|
||||
|
||||
@@ -245,6 +326,9 @@ class MatchParticipantRead(BaseModel):
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class AttachmentRead(BaseModel):
|
||||
@@ -256,6 +340,29 @@ class AttachmentRead(BaseModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
class MatchFinishDraftData(BaseModel):
|
||||
"""Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие,
|
||||
комментарии об игроках и причина победы. Промежуточное состояние, поэтому
|
||||
места не валидируются — человек раскладывает их постепенно."""
|
||||
|
||||
blocks: list[list[int]] = []
|
||||
eliminated: list[int] = []
|
||||
comments: dict[str, str] = {}
|
||||
win_reason: WinReason | None = None
|
||||
overall_comment: str | None = None
|
||||
end_round: EndRound | None = None
|
||||
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
|
||||
objectives: dict[str, Count] = {}
|
||||
worlds: dict[str, Count] = {}
|
||||
|
||||
|
||||
class MatchFinishDraftRead(BaseModel):
|
||||
data: MatchFinishDraftData
|
||||
updated_by: int | None = None
|
||||
updated_by_nickname: str | None = None
|
||||
updated_at: str
|
||||
|
||||
|
||||
class MatchRead(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
@@ -265,6 +372,11 @@ class MatchRead(BaseModel):
|
||||
finished_at: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
win_reason: WinReason | None = None
|
||||
end_round: int | None = None
|
||||
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
|
||||
# фронт берёт лимит отсюда, а не повторяет правило у себя.
|
||||
nine_rounds_rule: bool = False
|
||||
max_rounds: int
|
||||
player_count: int
|
||||
overall_comment: str | None = None
|
||||
created_by: int
|
||||
@@ -272,6 +384,8 @@ class MatchRead(BaseModel):
|
||||
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
|
||||
participants: list[MatchParticipantRead] = []
|
||||
attachments: list[AttachmentRead] = []
|
||||
# Общий черновик формы завершения (только у незавершённой партии).
|
||||
finish_draft: MatchFinishDraftRead | None = None
|
||||
|
||||
|
||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||
@@ -281,7 +395,8 @@ class OverallStats(BaseModel):
|
||||
wins: int
|
||||
win_rate: float
|
||||
avg_place: float | None = None
|
||||
score: float | None = None
|
||||
# Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
|
||||
score: int | None = None
|
||||
|
||||
|
||||
class LeaderboardEntry(OverallStats):
|
||||
@@ -289,6 +404,23 @@ class LeaderboardEntry(OverallStats):
|
||||
nickname: str
|
||||
rank: int | None = None
|
||||
avatar_url: str | None = None
|
||||
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
|
||||
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
|
||||
rating_confirmed: bool = False
|
||||
|
||||
|
||||
class MatchHistory(BaseModel):
|
||||
"""История партий игрока плюс настройки витрины его профиля.
|
||||
|
||||
Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
|
||||
как выбрал её владелец, не делая второго запроса за профилем."""
|
||||
|
||||
items: list[MatchListItem] = []
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
mode: str
|
||||
detail: str
|
||||
|
||||
|
||||
class Leaderboard(BaseModel):
|
||||
@@ -301,11 +433,15 @@ class FactionStat(BaseModel):
|
||||
faction_id: int
|
||||
code: str
|
||||
name_ru: str
|
||||
# Название в предложном падеже — для строки «Чаще всего играет на …».
|
||||
name_ru_prepositional: str
|
||||
expansion_code: str
|
||||
games: int
|
||||
wins: int
|
||||
win_rate: float
|
||||
avg_place: float | None = None
|
||||
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
|
||||
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
|
||||
score: float | None = None
|
||||
|
||||
|
||||
@@ -321,8 +457,14 @@ class ProfileStats(BaseModel):
|
||||
factions: list[FactionStat] = []
|
||||
best_faction: FactionStat | None = None
|
||||
worst_faction: FactionStat | None = None
|
||||
most_played_faction: FactionStat | None = None
|
||||
# Любимая — личный выбор игрока в профиле (не статистика).
|
||||
favorite_faction: FactionRead | None = None
|
||||
# «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи.
|
||||
main_faction: FactionStat | None = None
|
||||
recent_form: list[RecentFormItem] = []
|
||||
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
|
||||
# неподтверждённый рейтинг, не дублируя константу на фронте.
|
||||
min_games: int = 0
|
||||
|
||||
|
||||
class PublicProfile(BaseModel):
|
||||
@@ -363,6 +505,8 @@ class MatchListParticipant(BaseModel):
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
class MatchListItem(BaseModel):
|
||||
@@ -377,6 +521,9 @@ class MatchListItem(BaseModel):
|
||||
overall_comment: str | None = None
|
||||
created_by: int
|
||||
participants: list[MatchListParticipant] = []
|
||||
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
|
||||
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
|
||||
rating_delta: float | None = None
|
||||
|
||||
|
||||
class MatchList(BaseModel):
|
||||
@@ -430,6 +577,7 @@ class AdminUserRead(BaseModel):
|
||||
auth_provider: str
|
||||
telegram_id: int | None = None
|
||||
created_at: str
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
@@ -437,6 +585,11 @@ class AdminUserUpdate(BaseModel):
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminPasswordSet(BaseModel):
|
||||
# Новый пароль игроку от админа — способ восстановить забытый пароль.
|
||||
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||
|
||||
|
||||
class AdminGroupRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
@@ -494,3 +647,39 @@ class AuditLogList(BaseModel):
|
||||
items: list[AuditLogItem] = []
|
||||
limit: 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,7 +33,13 @@ FACTIONS: list[tuple[str, str, str, int]] = [
|
||||
|
||||
|
||||
def seed_reference_data(session: Session) -> None:
|
||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
|
||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
|
||||
|
||||
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
|
||||
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
|
||||
и после создания записи источник правды для него — БД (#72). Имя из кода получает
|
||||
только новая фракция; поправить имя существующей — админкой или миграцией.
|
||||
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
|
||||
code_to_expansion: dict[str, Expansion] = {}
|
||||
|
||||
for code, name_ru, is_base, order in EXPANSIONS:
|
||||
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
|
||||
)
|
||||
session.add(fac)
|
||||
else:
|
||||
fac.name_ru = name_ru
|
||||
fac.expansion_id = expansion.id # type: ignore[assignment]
|
||||
fac.sort_order = order
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ def _root() -> Path:
|
||||
return Path(settings.achievements_dir)
|
||||
|
||||
|
||||
# Формат slug — то, что выдаёт _slugify: только латиница, цифры и дефис.
|
||||
_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||
@@ -49,6 +53,11 @@ def _slugify(name: str) -> str:
|
||||
|
||||
|
||||
def _dir(slug: str) -> Path:
|
||||
"""Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого
|
||||
`..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы
|
||||
каталога ачивок."""
|
||||
if not _SLUG_RE.fullmatch(slug or ""):
|
||||
raise NotFoundError("Ачивка не найдена.")
|
||||
return _root() / slug
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import (
|
||||
ConflictError,
|
||||
InvalidCredentialsError,
|
||||
NicknameTakenError,
|
||||
NotFoundError,
|
||||
@@ -61,8 +62,20 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
||||
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 нет намеренно:
|
||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
|
||||
|
||||
|
||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||
@@ -75,6 +88,10 @@ def delete_group(session: Session, group_id: int) -> None:
|
||||
group = session.get(Group, group_id)
|
||||
if group is None:
|
||||
raise NotFoundError("Группа не найдена.")
|
||||
# matches.group_id — ON DELETE RESTRICT, поэтому группу с партиями БД не отдаст.
|
||||
# Проверяем сами, иначе IntegrityError уходит наружу голым 500 без AppError-конверта.
|
||||
if session.exec(select(Match.id).where(Match.group_id == group_id)).first() is not None:
|
||||
raise ConflictError("Нельзя удалить группу, в которой есть партии. Сначала удалите их.")
|
||||
session.delete(group)
|
||||
session.commit()
|
||||
|
||||
@@ -134,11 +151,16 @@ def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction:
|
||||
|
||||
|
||||
def delete_match(session: Session, match_id: int) -> None:
|
||||
from app.services import attachment_service # избегаем цикла импорта
|
||||
|
||||
match = session.get(Match, match_id)
|
||||
if match is None:
|
||||
raise NotFoundError("Партия не найдена.")
|
||||
session.delete(match)
|
||||
session.commit()
|
||||
# Как и в игроцком пути (match_service.delete_match): строки вложений уходят
|
||||
# каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда.
|
||||
attachment_service.delete_match_files(match_id)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Объявления администрации (#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()
|
||||
@@ -14,6 +14,7 @@ from app.core.errors import ConflictError, NotFoundError
|
||||
from app.models import Match, MatchAttachment, User
|
||||
|
||||
MAX_ATTACHMENTS = 10
|
||||
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 # 10 МБ
|
||||
_SUBDIR = "matches"
|
||||
|
||||
|
||||
@@ -42,6 +43,8 @@ def file_path(att: MatchAttachment) -> Path:
|
||||
def add_photo(
|
||||
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
|
||||
) -> MatchAttachment:
|
||||
from app.services import match_service # избегаем цикла импорта
|
||||
|
||||
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
|
||||
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
|
||||
att = MatchAttachment(
|
||||
@@ -60,6 +63,7 @@ def add_photo(
|
||||
abs_path.write_bytes(content)
|
||||
att.storage_path = rel
|
||||
session.add(att)
|
||||
match_service.touch(session, match)
|
||||
session.commit()
|
||||
session.refresh(att)
|
||||
return att
|
||||
@@ -73,6 +77,8 @@ def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachme
|
||||
|
||||
|
||||
def delete(session: Session, match: Match, att_id: int) -> None:
|
||||
from app.services import match_service # избегаем цикла импорта
|
||||
|
||||
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
|
||||
path = file_path(att)
|
||||
if path.exists():
|
||||
@@ -81,6 +87,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
|
||||
except OSError:
|
||||
pass
|
||||
session.delete(att)
|
||||
match_service.touch(session, match)
|
||||
session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]:
|
||||
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
# Предложный падеж названий фракций для фразы «Чаще всего играет на …».
|
||||
# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется.
|
||||
# Фракция, добавленная админом мимо словаря, выводится в именительном падеже.
|
||||
_PREPOSITIONAL: dict[str, str] = {
|
||||
"orks": "Орках",
|
||||
"ultramarines": "Ультрамаринах",
|
||||
"eldar": "Эльдарах",
|
||||
"chaos": "Хаоситах",
|
||||
"astra_militarum": "Имперской гвардии",
|
||||
"tau": "Тау",
|
||||
"necrons": "Некронах",
|
||||
"tyranids": "Тиранидах",
|
||||
"inquisition": "Инквизиции",
|
||||
"sisters_of_battle": "Сёстрах битвы",
|
||||
"drukhari": "Друкхари",
|
||||
"adeptus_mechanicus": "Адептус Механикус",
|
||||
}
|
||||
|
||||
|
||||
def prepositional(code: str, name_ru: str) -> str:
|
||||
"""Название фракции в предложном падеже; вне словаря — именительный как есть."""
|
||||
return _PREPOSITIONAL.get(code, name_ru)
|
||||
|
||||
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
|
||||
return group
|
||||
|
||||
|
||||
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
|
||||
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
|
||||
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
|
||||
group.nine_rounds_rule = enabled
|
||||
session.add(group)
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
|
||||
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
||||
current = session.exec(
|
||||
|
||||
@@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||
@@ -70,17 +71,17 @@ def create_invitation(
|
||||
|
||||
|
||||
def list_for_user(session: Session, user_id: int) -> list[dict]:
|
||||
inviter = aliased(User)
|
||||
rows = session.exec(
|
||||
select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id)
|
||||
select(GroupInvitation, Group.name, inviter.nickname)
|
||||
.join(Group, Group.id == GroupInvitation.group_id)
|
||||
# LEFT JOIN: пригласивший мог быть удалён (invited_by_id → SET NULL).
|
||||
.join(inviter, inviter.id == GroupInvitation.invited_by_id, isouter=True)
|
||||
.where(GroupInvitation.user_id == user_id)
|
||||
.order_by(GroupInvitation.created_at.desc())
|
||||
).all()
|
||||
out = []
|
||||
for inv, gname in rows:
|
||||
inviter_nick = None
|
||||
if inv.invited_by_id is not None:
|
||||
inviter = session.get(User, inv.invited_by_id)
|
||||
inviter_nick = inviter.nickname if inviter else None
|
||||
for inv, gname, inviter_nick in rows:
|
||||
out.append(
|
||||
{
|
||||
"id": inv.id,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -16,12 +16,15 @@ from app.core.errors import (
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.timeutil import app_today, iso_utc
|
||||
from app.models import Faction, GroupMember, Match, MatchParticipant, User
|
||||
from app.core.timeutil import app_today, iso_utc, utcnow
|
||||
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
|
||||
from app.services import group_service
|
||||
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
|
||||
|
||||
MAX_MATCH_PLAYERS = 6
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
|
||||
LAST_STANDING = "last_standing"
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
|
||||
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,11 +45,13 @@ class FinishInput:
|
||||
eliminated: bool = False
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParticipantInput:
|
||||
"""Полный участник (для правки завершённой партии админом)."""
|
||||
"""Полный участник (для правки результатов завершённой партии)."""
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
@@ -54,10 +59,8 @@ class ParticipantInput:
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
def round_to_30(minutes: float) -> int:
|
||||
@@ -77,6 +80,17 @@ def match_version(match: Match) -> str:
|
||||
return iso_utc(match.updated_at)
|
||||
|
||||
|
||||
def touch(session: Session, match: Match) -> None:
|
||||
"""Пометить партию изменённой (без commit).
|
||||
|
||||
Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy
|
||||
срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь
|
||||
связанные сущности (участники, вложения), строку не трогают, поэтому каждая
|
||||
такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча."""
|
||||
match.updated_at = utcnow()
|
||||
session.add(match)
|
||||
|
||||
|
||||
def assert_version(match: Match, expected: str | None) -> None:
|
||||
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
|
||||
if expected is not None and expected != match_version(match):
|
||||
@@ -147,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int
|
||||
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
|
||||
|
||||
|
||||
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
|
||||
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
|
||||
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
|
||||
|
||||
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
|
||||
if end_round is None:
|
||||
return
|
||||
rmax = max_rounds(player_count, nine_rounds_rule)
|
||||
if not 1 <= end_round <= rmax:
|
||||
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
|
||||
|
||||
|
||||
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
|
||||
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
|
||||
if not eliminated:
|
||||
return worlds
|
||||
if worlds:
|
||||
raise ValidationError("У выбывшего игрока не может быть миров.")
|
||||
return 0
|
||||
|
||||
|
||||
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
|
||||
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
|
||||
|
||||
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
|
||||
сервер лишь не пропускает расхождение."""
|
||||
alone = sum(1 for e in eliminated if not e) == 1
|
||||
if alone and win_reason != LAST_STANDING:
|
||||
raise ValidationError(
|
||||
"Остался один невыбывший игрок — причина победы «последний выживший»."
|
||||
)
|
||||
if not alone and win_reason == LAST_STANDING:
|
||||
raise ValidationError(
|
||||
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
|
||||
)
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {
|
||||
m.user_id
|
||||
@@ -161,16 +212,28 @@ def _validate_roster_basics(
|
||||
group_id: int,
|
||||
user_ids: list[int],
|
||||
faction_ids: list[int],
|
||||
*,
|
||||
keep_user_ids: set[int] | None = None,
|
||||
keep_faction_ids: set[int] | None = None,
|
||||
) -> None:
|
||||
"""Состав партии: размер, отсутствие дублей, принадлежность группе.
|
||||
|
||||
keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят
|
||||
независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из
|
||||
группы игрок делали бы старую партию неисправимой навсегда."""
|
||||
if len(user_ids) < 2:
|
||||
raise ValidationError("В партии должно быть не менее 2 участников.")
|
||||
if len(user_ids) > MAX_MATCH_PLAYERS:
|
||||
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
||||
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
||||
raise DuplicateParticipantError()
|
||||
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
|
||||
allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set())
|
||||
if not set(user_ids).issubset(allowed_users):
|
||||
raise ValidationError("Все участники должны состоять в группе.")
|
||||
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
|
||||
allowed_factions = group_service.available_faction_ids(session, group_id) | (
|
||||
keep_faction_ids or set()
|
||||
)
|
||||
if not set(faction_ids).issubset(allowed_factions):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
|
||||
@@ -188,13 +251,16 @@ def create_match(
|
||||
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
|
||||
)
|
||||
|
||||
now = _utcnow()
|
||||
now = utcnow()
|
||||
group = group_service.get_group(session, group_id)
|
||||
match = Match(
|
||||
group_id=group_id,
|
||||
status="in_progress",
|
||||
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
||||
started_at=now,
|
||||
player_count=len(roster),
|
||||
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
|
||||
nine_rounds_rule=group.nine_rounds_rule,
|
||||
created_by=creator.id, # type: ignore[arg-type]
|
||||
)
|
||||
session.add(match)
|
||||
@@ -223,6 +289,7 @@ def finish_match(
|
||||
*,
|
||||
finish: list[FinishInput],
|
||||
win_reason: str,
|
||||
end_round: int | None = None,
|
||||
overall_comment: str | None = None,
|
||||
overall_comment_set: bool = False,
|
||||
expected_version: str | None = None,
|
||||
@@ -232,6 +299,7 @@ def finish_match(
|
||||
raise ConflictError("Партия уже завершена.")
|
||||
if win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Укажите корректную причину победы.")
|
||||
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
|
||||
|
||||
existing = {
|
||||
p.user_id: p
|
||||
@@ -254,17 +322,21 @@ def finish_match(
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
|
||||
_check_last_standing(win_reason, [f.eliminated for f in finish])
|
||||
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish}
|
||||
|
||||
for f in finish:
|
||||
p = existing[f.user_id]
|
||||
p.place = places[f.user_id]
|
||||
p.eliminated = f.eliminated
|
||||
p.comment = f.comment or None
|
||||
p.objectives = f.objectives
|
||||
p.worlds = worlds[f.user_id]
|
||||
if f.faction_id is not None:
|
||||
p.faction_id = f.faction_id
|
||||
session.add(p)
|
||||
|
||||
now = _utcnow()
|
||||
now = utcnow()
|
||||
match.finished_at = now
|
||||
started = match.started_at
|
||||
if started is not None:
|
||||
@@ -275,15 +347,123 @@ def finish_match(
|
||||
match.duration_minutes = round_to_30(elapsed_min)
|
||||
match.status = "finished"
|
||||
match.win_reason = win_reason
|
||||
match.end_round = end_round
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
|
||||
session.add(match)
|
||||
# Содержимое черновика уже в результатах — второй источник правды не нужен.
|
||||
clear_finish_draft(session, match.id) # type: ignore[arg-type]
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
|
||||
# ─── Черновик завершения (совместное заполнение формы) ────────────────────────
|
||||
|
||||
def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None:
|
||||
return session.get(MatchFinishDraft, match_id)
|
||||
|
||||
|
||||
def _draft_counts(value: object) -> dict[str, int]:
|
||||
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
out: dict[str, int] = {}
|
||||
for k, v in value.items():
|
||||
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
out[str(k)] = v
|
||||
return out
|
||||
|
||||
|
||||
def _validate_draft(session: Session, match: Match, data: dict) -> dict:
|
||||
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
|
||||
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
|
||||
раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
|
||||
причине не проверяется и правило «последнего выжившего»."""
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
blocks = data.get("blocks") or []
|
||||
eliminated = data.get("eliminated") or []
|
||||
comments = data.get("comments") or {}
|
||||
win_reason = data.get("win_reason")
|
||||
end_round = data.get("end_round")
|
||||
if not isinstance(blocks, list) or not isinstance(eliminated, list):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
if not isinstance(comments, dict):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Некорректная причина победы.")
|
||||
if end_round is not None and (
|
||||
isinstance(end_round, bool)
|
||||
or not isinstance(end_round, int)
|
||||
or not 1 <= end_round <= EXTENDED_ROUNDS
|
||||
):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
objectives = _draft_counts(data.get("objectives") or {})
|
||||
worlds = _draft_counts(data.get("worlds") or {})
|
||||
|
||||
participant_ids = {
|
||||
p.user_id
|
||||
for p in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all()
|
||||
}
|
||||
listed: list[int] = []
|
||||
for block in blocks:
|
||||
if not isinstance(block, list):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
listed.extend(block)
|
||||
listed.extend(eliminated)
|
||||
if any(not isinstance(uid, int) for uid in listed):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
if set(listed) - participant_ids:
|
||||
raise ValidationError("В черновике есть игроки не из этой партии.")
|
||||
overall = data.get("overall_comment")
|
||||
if overall is not None and not isinstance(overall, str):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
return {
|
||||
"blocks": blocks,
|
||||
"eliminated": eliminated,
|
||||
"comments": {str(k): str(v) for k, v in comments.items()},
|
||||
"win_reason": win_reason,
|
||||
"overall_comment": overall,
|
||||
"end_round": end_round,
|
||||
"objectives": objectives,
|
||||
"worlds": worlds,
|
||||
}
|
||||
|
||||
|
||||
def save_finish_draft(
|
||||
session: Session, match: Match, user: User, data: dict
|
||||
) -> MatchFinishDraft:
|
||||
"""Сохранить общий черновик формы завершения (последняя запись побеждает).
|
||||
|
||||
Версию партии (updated_at) намеренно НЕ двигаем: иначе «Завершить» у второго
|
||||
участника ловил бы STALE_WRITE на каждую чужую правку черновика."""
|
||||
if match.status != "in_progress":
|
||||
raise ConflictError("Партия уже завершена.")
|
||||
payload = _validate_draft(session, match, data)
|
||||
draft = session.get(MatchFinishDraft, match.id)
|
||||
if draft is None:
|
||||
draft = MatchFinishDraft(match_id=match.id, data=payload, updated_by=user.id)
|
||||
else:
|
||||
draft.data = payload
|
||||
draft.updated_by = user.id
|
||||
draft.updated_at = utcnow()
|
||||
session.add(draft)
|
||||
session.commit()
|
||||
session.refresh(draft)
|
||||
return draft
|
||||
|
||||
|
||||
def clear_finish_draft(session: Session, match_id: int) -> None:
|
||||
draft = session.get(MatchFinishDraft, match_id)
|
||||
if draft is not None:
|
||||
session.delete(draft)
|
||||
|
||||
# ─── Права / правка / удаление ────────────────────────────────────────────────
|
||||
|
||||
def can_modify(session: Session, match: Match, user: User) -> bool:
|
||||
@@ -308,33 +488,74 @@ def update_match(
|
||||
overall_comment_set: bool = False,
|
||||
win_reason: str | None = None,
|
||||
win_reason_set: bool = False,
|
||||
end_round: int | None = None,
|
||||
end_round_set: bool = False,
|
||||
participants: list[ParticipantInput] | None = None,
|
||||
expected_version: str | None = None,
|
||||
) -> Match:
|
||||
"""Правка завершённой партии (админ): полный список участников с местами."""
|
||||
assert_version(match, expected_version)
|
||||
if played_at is not None:
|
||||
match.played_at = played_at
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
if win_reason_set:
|
||||
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Некорректная причина победы.")
|
||||
match.win_reason = win_reason
|
||||
"""Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
|
||||
|
||||
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
|
||||
иначе они оседали бы в партии со статусом in_progress, которая остаётся
|
||||
в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
|
||||
только status='finished'). Дату и общий комментарий править можно и по ходу
|
||||
партии — двойственного состояния они не создают."""
|
||||
results_touched = participants is not None or win_reason_set or end_round_set
|
||||
if results_touched and match.status != "finished":
|
||||
raise ConflictError(
|
||||
"Результаты незавершённой партии нельзя править — сначала завершите её."
|
||||
)
|
||||
assert_version(match, expected_version)
|
||||
if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Некорректная причина победы.")
|
||||
|
||||
saved = session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all()
|
||||
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
|
||||
# с тем, что уже записано.
|
||||
new_reason = win_reason if win_reason_set else match.win_reason
|
||||
new_end_round = end_round if end_round_set else match.end_round
|
||||
new_count = len(participants) if participants is not None else match.player_count
|
||||
if end_round_set or participants is not None:
|
||||
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
|
||||
|
||||
places: dict[int, int] = {}
|
||||
worlds: dict[int, int | None] = {}
|
||||
if participants is not None:
|
||||
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||
_validate_roster_basics(
|
||||
session,
|
||||
match.group_id,
|
||||
[p.user_id for p in participants],
|
||||
[p.faction_id for p in participants],
|
||||
keep_user_ids={p.user_id for p in saved},
|
||||
keep_faction_ids={p.faction_id for p in saved},
|
||||
)
|
||||
places = _resolve_finish_places(
|
||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||
)
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
|
||||
if participants is not None or win_reason_set:
|
||||
flags = (
|
||||
[p.eliminated for p in participants]
|
||||
if participants is not None
|
||||
else [p.eliminated for p in saved]
|
||||
)
|
||||
_check_last_standing(new_reason, flags)
|
||||
|
||||
if played_at is not None:
|
||||
match.played_at = played_at
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
if win_reason_set:
|
||||
match.win_reason = win_reason
|
||||
if end_round_set:
|
||||
match.end_round = end_round
|
||||
|
||||
if participants is not None:
|
||||
for old in saved:
|
||||
session.delete(old)
|
||||
session.flush()
|
||||
for p in participants:
|
||||
@@ -347,11 +568,13 @@ def update_match(
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment or None,
|
||||
objectives=p.objectives,
|
||||
worlds=worlds[p.user_id],
|
||||
)
|
||||
)
|
||||
match.player_count = len(participants)
|
||||
|
||||
session.add(match)
|
||||
touch(session, match)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
from app.models import Group, GroupMember, User
|
||||
from app.services import group_service
|
||||
|
||||
MAX_GROUP_SIZE = 10
|
||||
|
||||
@@ -33,11 +34,7 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
|
||||
if member_count >= MAX_GROUP_SIZE:
|
||||
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
|
||||
|
||||
existing = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
existing = group_service.get_membership(session, group.id, user.id)
|
||||
if existing is not None:
|
||||
raise ConflictError("Игрок уже в группе.")
|
||||
|
||||
@@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
|
||||
return member, user
|
||||
|
||||
|
||||
def remove_member(session: Session, group: Group, user_id: int) -> None:
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None:
|
||||
"""Группа без владельца неисправима: назначить нового становится некому."""
|
||||
if member.role != "owner":
|
||||
return
|
||||
owners = session.exec(
|
||||
select(GroupMember.id).where(
|
||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||
)
|
||||
).first()
|
||||
).all()
|
||||
if len(owners) <= 1:
|
||||
raise ForbiddenError(message)
|
||||
|
||||
|
||||
def remove_member(session: Session, group: Group, user_id: int) -> None:
|
||||
member = group_service.get_membership(session, group.id, user_id)
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
if member.role == "owner":
|
||||
owners = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||
)
|
||||
).all()
|
||||
if len(owners) <= 1:
|
||||
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
|
||||
_assert_not_last_owner(
|
||||
session, group, member, "Нельзя удалить последнего владельца группы."
|
||||
)
|
||||
|
||||
# Сбросить активную группу у тех, для кого она была активной.
|
||||
user = session.get(User, user_id)
|
||||
@@ -78,13 +79,13 @@ def remove_member(session: Session, group: Group, user_id: int) -> None:
|
||||
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
|
||||
if role not in ("owner", "member"):
|
||||
raise ValidationError("Недопустимая роль.")
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
member = group_service.get_membership(session, group.id, user_id)
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
if role != "owner":
|
||||
_assert_not_last_owner(
|
||||
session, group, member, "Нельзя снять роль с последнего владельца группы."
|
||||
)
|
||||
member.role = role
|
||||
session.add(member)
|
||||
session.commit()
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу
|
||||
подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере.
|
||||
Хранение — 72 часа; протухшие удаляются при чтении списка и фоновой задачей.
|
||||
Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл),
|
||||
прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой
|
||||
задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +16,8 @@ from app.core.timeutil import iso_utc
|
||||
from app.models import Group, Match, MatchParticipant, Notification
|
||||
from app.services import notify
|
||||
|
||||
RETENTION_HOURS = 72
|
||||
RETENTION_HOURS = 72 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения
|
||||
MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -45,6 +48,9 @@ def create_for(
|
||||
Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids
|
||||
]
|
||||
session.add_all(rows)
|
||||
session.flush()
|
||||
for uid in ids:
|
||||
_cap_user(session, uid)
|
||||
session.commit()
|
||||
for uid in ids:
|
||||
notify.notifications_changed(uid)
|
||||
@@ -64,10 +70,27 @@ def create(
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _cap_user(session: Session, user_id: int) -> None:
|
||||
"""Удалить старейшие уведомления пользователя сверх MAX_PER_USER (без commit)."""
|
||||
extra = session.exec(
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.order_by(Notification.created_at.desc(), Notification.id.desc())
|
||||
.offset(MAX_PER_USER)
|
||||
).all()
|
||||
for row in extra:
|
||||
session.delete(row)
|
||||
|
||||
|
||||
def purge_expired(session: Session) -> int:
|
||||
"""Удалить уведомления старше RETENTION_HOURS. Возвращает число удалённых."""
|
||||
"""Удалить ПРОЧИТАННЫЕ уведомления спустя RETENTION_HOURS после прочтения.
|
||||
|
||||
Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна
|
||||
хранения, не увидел бы их вовсе. Возвращает число удалённых."""
|
||||
rows = session.exec(
|
||||
select(Notification).where(Notification.created_at < _cutoff())
|
||||
select(Notification).where(
|
||||
Notification.read_at.is_not(None), Notification.read_at < _cutoff()
|
||||
)
|
||||
).all()
|
||||
for row in rows:
|
||||
session.delete(row)
|
||||
@@ -77,7 +100,7 @@ def purge_expired(session: Session) -> int:
|
||||
|
||||
|
||||
def list_for_user(session: Session, user_id: int) -> dict:
|
||||
"""Свежие (<72ч) уведомления пользователя + число непрочитанных. Чистит протухшие."""
|
||||
"""Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные."""
|
||||
purge_expired(session)
|
||||
rows = session.exec(
|
||||
select(Notification)
|
||||
@@ -114,6 +137,9 @@ def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> i
|
||||
session.add(row)
|
||||
if rows:
|
||||
session.commit()
|
||||
# Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for,
|
||||
# иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки.
|
||||
notify.notifications_changed(user_id)
|
||||
return len(rows)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.events import hub
|
||||
from app.models import GroupMember, Match
|
||||
from app.models import GroupMember, Match, User
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
@@ -17,20 +17,47 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
)
|
||||
|
||||
|
||||
def _ratings_changed(session: Session, notified: list[int]) -> None:
|
||||
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
|
||||
главную, историю и профили всех, кто играл после неё, и страницы других групп.
|
||||
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
|
||||
skip = set(notified)
|
||||
ids = [
|
||||
uid
|
||||
for uid in session.exec(
|
||||
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
||||
).all()
|
||||
if uid not in skip
|
||||
]
|
||||
hub.publish(ids, {"type": "ratings"})
|
||||
|
||||
|
||||
def match_changed(session: Session, match: Match) -> None:
|
||||
"""Партия изменилась — уведомить всех участников её группы."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, match.group_id),
|
||||
{"type": "match", "match_id": match.id, "group_id": match.group_id},
|
||||
)
|
||||
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
|
||||
и остальных игроков (_ratings_changed)."""
|
||||
members = _group_member_ids(session, match.group_id)
|
||||
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
|
||||
if match.status == "finished":
|
||||
_ratings_changed(session, members)
|
||||
|
||||
|
||||
def match_removed(session: Session, match_id: int, group_id: int) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки)."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, group_id),
|
||||
{"type": "match", "match_id": match_id, "group_id": group_id},
|
||||
)
|
||||
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||
"""Черновик формы завершения изменился — остальным заполняющим из группы.
|
||||
|
||||
Отдельный тип события: черновик меняется на каждое движение тайла, и гнать по нему
|
||||
полную инвалидацию (лидерборд, история, профили) было бы расточительно. Автору
|
||||
правки событие не шлём — у него уже актуальное состояние."""
|
||||
ids = [uid for uid in _group_member_ids(session, match.group_id) if uid != actor_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:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
|
||||
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
|
||||
members = _group_member_ids(session, group_id)
|
||||
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
|
||||
if finished:
|
||||
_ratings_changed(session, members)
|
||||
|
||||
|
||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||
@@ -49,3 +76,13 @@ def invitations_changed(user_id: int) -> None:
|
||||
def notifications_changed(user_id: int) -> None:
|
||||
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
|
||||
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"})
|
||||
|
||||
+205
-17
@@ -1,32 +1,220 @@
|
||||
"""Метрика рейтинга. Вынесена отдельно — легко заменить.
|
||||
"""Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23).
|
||||
|
||||
По умолчанию: League Points — нормированные очки за место с учётом размера стола
|
||||
и ничьих (competition ranking). За партию из N игроков:
|
||||
points = (N - place - (tie_size - 1)/2) / (N - 1)
|
||||
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
|
||||
Рейтинговый счёт игрока = AVG(points) * 100.
|
||||
Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md;
|
||||
эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней).
|
||||
|
||||
Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b:
|
||||
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
||||
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
||||
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
||||
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
|
||||
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
|
||||
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
|
||||
признак партии подставляется типичным и не влияет на M.
|
||||
|
||||
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
||||
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from itertools import combinations
|
||||
|
||||
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
||||
MIN_GAMES = 3
|
||||
MIN_GAMES = 10
|
||||
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
||||
FACTION_MIN_GAMES = 2
|
||||
|
||||
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count,
|
||||
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
|
||||
MATCH_POINTS_SQL = (
|
||||
"CASE WHEN m.player_count > 1 "
|
||||
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
|
||||
"/ (m.player_count - 1) "
|
||||
"ELSE 1.0 END"
|
||||
)
|
||||
# ─── Правила игры ────────────────────────────────────────────────────────────
|
||||
|
||||
# Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5).
|
||||
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
|
||||
WORLDS_PER_TILE = 2.2
|
||||
BASE_ROUNDS = 8
|
||||
# Домашнее правило группы: при 5–6 игроках играется 9 раундов.
|
||||
EXTENDED_ROUNDS = 9
|
||||
EXTENDED_MIN_PLAYERS = 5
|
||||
|
||||
# ─── Коэффициенты (документ, 4.10) ───────────────────────────────────────────
|
||||
|
||||
R0 = 1500.0 # стартовый рейтинг
|
||||
D = 400.0 # масштаб: разница 400 пунктов — шансы 10:1
|
||||
K_MAX = 64.0 # K новичка (0 партий)
|
||||
K_MIN = 16.0 # K опытного игрока
|
||||
K_GAMES = 20 # за сколько партий K линейно спускается от K_MAX к K_MIN
|
||||
W_TABLE = 0.5 # вес размера стола
|
||||
W_TEMPO = 1.0 # вес темпа победы
|
||||
W_OBJ = 0.5 # вес отрыва по целям
|
||||
W_WORLDS = 0.5 # вес отрыва по мирам
|
||||
MU_OBJ = 0.5 # типичный отрыв по целям
|
||||
MU_WORLDS = 0.5 # типичный отрыв по мирам
|
||||
M_MIN = 0.5 # страховка: одна партия не легче половины обычной…
|
||||
M_MAX = 2.0 # …и не тяжелее двух
|
||||
# Близость партии по типу победы — множитель пар с победителем.
|
||||
CLOSENESS = {
|
||||
"objectives": 1.0,
|
||||
"worlds": 0.85,
|
||||
"plastic": 0.7,
|
||||
"resources": 0.6,
|
||||
"last_standing": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def max_rounds(player_count: int, nine_rounds_rule: bool) -> int:
|
||||
"""Лимит раундов партии: 9 при хоумруле группы и 5+ игроках, иначе 8."""
|
||||
if nine_rounds_rule and player_count >= EXTENDED_MIN_PLAYERS:
|
||||
return EXTENDED_ROUNDS
|
||||
return BASE_ROUNDS
|
||||
|
||||
|
||||
def fair_worlds(player_count: int) -> float:
|
||||
"""«Честная доля» миров на игрока — масштаб для разницы миров."""
|
||||
return BOARD_TILES[player_count] * WORLDS_PER_TILE / player_count
|
||||
|
||||
|
||||
def mu_tempo(rmax: int) -> float:
|
||||
"""Типичный темп: партия закончилась в предпоследнем раунде."""
|
||||
return 1.0 / (rmax - 1)
|
||||
|
||||
|
||||
def expected(r_a: float, r_b: float) -> float:
|
||||
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
|
||||
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / D))
|
||||
|
||||
|
||||
def k_factor(games: int) -> float:
|
||||
left = max(0.0, 1.0 - games / K_GAMES)
|
||||
return K_MIN + (K_MAX - K_MIN) * left
|
||||
|
||||
|
||||
def table_weight(player_count: int) -> float:
|
||||
return 1.0 + W_TABLE * (player_count - 2) / 4.0
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
# ─── Партия как вход расчёта ─────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RatedSeat:
|
||||
user_id: int
|
||||
place: int
|
||||
faction_id: int = 0
|
||||
eliminated: bool = False
|
||||
objectives: int | None = None # маркеры целей на конец партии
|
||||
worlds: int | None = None # дружественные миры на конец партии
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RatedMatch:
|
||||
seats: tuple[RatedSeat, ...]
|
||||
win_reason: str | None = None
|
||||
end_round: int | None = None # раунд, в котором партия закончилась
|
||||
nine_rounds_rule: bool = False # снимок настройки группы на момент партии
|
||||
id: int = 0
|
||||
group_id: int = 0
|
||||
played_at: date | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
|
||||
def pair_multiplier(m: RatedMatch, a: RatedSeat, b: RatedSeat) -> float:
|
||||
"""Множитель отрыва пары; a — выше или наравне с b."""
|
||||
n = len(m.seats)
|
||||
tie = a.place == b.place
|
||||
winner_pair = a.place == 1
|
||||
add = 1.0
|
||||
|
||||
def diff(x: int, y: int) -> float:
|
||||
return abs(x - y) if tie else max(0, x - y)
|
||||
|
||||
if winner_pair:
|
||||
rmax = max_rounds(n, m.nine_rounds_rule)
|
||||
mu = mu_tempo(rmax)
|
||||
tempo = mu if m.end_round is None else (rmax - m.end_round) / (rmax - 1)
|
||||
add += W_TEMPO * (tempo - mu)
|
||||
if winner_pair and m.win_reason == "last_standing":
|
||||
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
|
||||
elif a.objectives is not None and b.objectives is not None:
|
||||
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
|
||||
else:
|
||||
obj = MU_OBJ
|
||||
add += W_OBJ * (obj - MU_OBJ)
|
||||
if a.worlds is not None and b.worlds is not None:
|
||||
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
|
||||
else:
|
||||
wor = MU_WORLDS
|
||||
add += W_WORLDS * (wor - MU_WORLDS)
|
||||
|
||||
close = CLOSENESS.get(m.win_reason, 1.0) if winner_pair and m.win_reason else 1.0
|
||||
return _clamp(add, M_MIN, M_MAX) * close
|
||||
|
||||
|
||||
def rate_match(
|
||||
ratings: dict[int, float], games: dict[int, int], m: RatedMatch
|
||||
) -> tuple[dict[int, float], dict[int, float]]:
|
||||
"""Изменения рейтинга участников и их результат относительно ожидания.
|
||||
|
||||
Возвращает (ΔR, perf): perf_i = Σ_j (S_ij − E_ij) / (N − 1) — насколько игрок
|
||||
выступил выше ожидания, без множителя отрыва и K. Входные словари не мутирует."""
|
||||
n = len(m.seats)
|
||||
delta = {s.user_id: 0.0 for s in m.seats}
|
||||
perf = {s.user_id: 0.0 for s in m.seats}
|
||||
if n < 2:
|
||||
return delta, perf
|
||||
g = table_weight(n)
|
||||
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
||||
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
|
||||
for a, b in combinations(m.seats, 2):
|
||||
if a.eliminated and b.eliminated:
|
||||
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
|
||||
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
|
||||
continue
|
||||
if a.place > b.place:
|
||||
a, b = b, a
|
||||
s_ab = 0.5 if a.place == b.place else 1.0
|
||||
e_ab = expected(r[a.user_id], r[b.user_id])
|
||||
x = pair_multiplier(m, a, b) * (s_ab - e_ab)
|
||||
delta[a.user_id] += k[a.user_id] * g / (n - 1) * x
|
||||
delta[b.user_id] -= k[b.user_id] * g / (n - 1) * x
|
||||
perf[a.user_id] += (s_ab - e_ab) / (n - 1)
|
||||
perf[b.user_id] -= (s_ab - e_ab) / (n - 1)
|
||||
return delta, perf
|
||||
|
||||
|
||||
@dataclass
|
||||
class Replay:
|
||||
"""Итог проигрывания истории: рейтинги без округления и следы каждой партии."""
|
||||
|
||||
ratings: dict[int, float] = field(default_factory=dict)
|
||||
games: dict[int, int] = field(default_factory=dict)
|
||||
delta: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
|
||||
perf: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
|
||||
|
||||
|
||||
def replay(matches: Iterable[RatedMatch]) -> Replay:
|
||||
"""Проигрывает партии в переданном порядке (хронологию задаёт вызывающий)."""
|
||||
out = Replay()
|
||||
for m in matches:
|
||||
delta, perf = rate_match(out.ratings, out.games, m)
|
||||
for uid, dv in delta.items():
|
||||
out.ratings[uid] = out.ratings.get(uid, R0) + dv
|
||||
out.games[uid] = out.games.get(uid, 0) + 1
|
||||
out.delta[(m.id, uid)] = dv
|
||||
out.perf[(m.id, uid)] = perf[uid]
|
||||
return out
|
||||
|
||||
|
||||
def leaderboard_sort_key(row: dict) -> tuple:
|
||||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||
"""Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
|
||||
|
||||
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
|
||||
всё равно упорядочены по настоящему значению."""
|
||||
return (
|
||||
-(row["score"] or 0.0),
|
||||
-(row["rating"] or 0.0),
|
||||
-(row["win_rate"] or 0.0),
|
||||
-(row["games"] or 0),
|
||||
(row["avg_place"] or 0.0),
|
||||
|
||||
@@ -1,102 +1,183 @@
|
||||
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
|
||||
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
|
||||
|
||||
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
|
||||
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
|
||||
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
|
||||
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
|
||||
винрейт и среднее место считает по партиям группы."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import Group, GroupMember, Match
|
||||
from app.services import group_service, membership_service, user_service
|
||||
from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User
|
||||
from app.services import faction_service, group_service, membership_service, user_service
|
||||
from app.services.scoring import (
|
||||
FACTION_MIN_GAMES,
|
||||
MATCH_POINTS_SQL,
|
||||
MIN_GAMES,
|
||||
RatedMatch,
|
||||
RatedSeat,
|
||||
Replay,
|
||||
leaderboard_sort_key,
|
||||
replay,
|
||||
)
|
||||
|
||||
# Базовый блок: одна строка на участие с tie-aware очками.
|
||||
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
|
||||
SCORED_CTE = f"""
|
||||
WITH tie AS (
|
||||
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
WHERE m.status = 'finished' AND mp.place IS NOT NULL
|
||||
GROUP BY mp.match_id, mp.place
|
||||
),
|
||||
scored AS (
|
||||
SELECT mp.user_id AS user_id,
|
||||
mp.faction_id AS faction_id,
|
||||
m.id AS match_id,
|
||||
m.group_id AS group_id,
|
||||
m.played_at AS played_at,
|
||||
mp.place AS place,
|
||||
m.player_count AS player_count,
|
||||
({MATCH_POINTS_SQL}) AS points,
|
||||
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
|
||||
WHERE m.status = 'finished'
|
||||
)
|
||||
"""
|
||||
|
||||
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
|
||||
|
||||
|
||||
def _round(value: Any, ndigits: int) -> float | None:
|
||||
return None if value is None else round(float(value), ndigits)
|
||||
def _playable_match_ids():
|
||||
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
|
||||
|
||||
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
|
||||
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
|
||||
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
|
||||
return (
|
||||
select(MatchParticipant.match_id)
|
||||
.group_by(MatchParticipant.match_id)
|
||||
.having(func.count() >= MIN_PARTICIPANTS)
|
||||
)
|
||||
|
||||
|
||||
def _normalize(row: dict) -> dict:
|
||||
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
|
||||
avatar_url = None
|
||||
if row.get("avatar_path"):
|
||||
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
|
||||
def load_history(session: Session) -> list[RatedMatch]:
|
||||
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
||||
|
||||
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
|
||||
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
|
||||
_for_group по этой же истории: рейтинг считается только целиком."""
|
||||
stmt = (
|
||||
select(Match, MatchParticipant)
|
||||
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||
.where(Match.status == "finished")
|
||||
.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
|
||||
)
|
||||
|
||||
history: list[RatedMatch] = []
|
||||
current: Match | None = None
|
||||
seats: list[RatedSeat] = []
|
||||
|
||||
def flush() -> None:
|
||||
if current is not None and len(seats) >= MIN_PARTICIPANTS:
|
||||
history.append(
|
||||
RatedMatch(
|
||||
seats=tuple(seats),
|
||||
win_reason=current.win_reason,
|
||||
end_round=current.end_round,
|
||||
nine_rounds_rule=current.nine_rounds_rule,
|
||||
id=current.id, # type: ignore[arg-type]
|
||||
group_id=current.group_id,
|
||||
played_at=current.played_at,
|
||||
finished_at=current.finished_at,
|
||||
)
|
||||
)
|
||||
|
||||
for m, p in session.exec(stmt).all():
|
||||
if current is None or m.id != current.id:
|
||||
flush()
|
||||
current, seats = m, []
|
||||
if p.place is None: # у завершённой партии мест без значения не бывает
|
||||
continue
|
||||
seats.append(
|
||||
RatedSeat(
|
||||
user_id=p.user_id,
|
||||
place=p.place,
|
||||
faction_id=p.faction_id,
|
||||
eliminated=p.eliminated,
|
||||
objectives=p.objectives,
|
||||
worlds=p.worlds,
|
||||
)
|
||||
)
|
||||
flush()
|
||||
return history
|
||||
|
||||
|
||||
def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]:
|
||||
return [m for m in history if m.group_id == group_id]
|
||||
|
||||
|
||||
def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]:
|
||||
return [(m, s) for m in history for s in m.seats if s.user_id == user_id]
|
||||
|
||||
|
||||
def _rating_confirmed(rep: Replay, user_id: int) -> bool:
|
||||
"""Рейтинг подтверждён, когда за игроком MIN_GAMES партий во всём приложении."""
|
||||
return rep.games.get(user_id, 0) >= MIN_GAMES
|
||||
|
||||
|
||||
def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict:
|
||||
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
|
||||
|
||||
В расчёте рейтинг без округления (иначе ошибка копилась бы по цепочке партий),
|
||||
показывается — целым."""
|
||||
games = len(seats)
|
||||
if games == 0:
|
||||
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
|
||||
wins = sum(1 for _m, s in seats if s.place == 1)
|
||||
return {
|
||||
"user_id": row["user_id"],
|
||||
"nickname": row["nickname"],
|
||||
"games": int(row["games"] or 0),
|
||||
"wins": int(row["wins"] or 0),
|
||||
"win_rate": _round(row["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(row["avg_place"], 2),
|
||||
"score": _round(row["score"], 1),
|
||||
"avatar_url": avatar_url,
|
||||
"games": games,
|
||||
"wins": wins,
|
||||
"win_rate": round(wins / games, 4),
|
||||
"avg_place": round(sum(s.place for _m, s in seats) / games, 2),
|
||||
"score": None if rating is None else round(rating),
|
||||
}
|
||||
|
||||
|
||||
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
|
||||
where = "WHERE s.group_id = :gid" if group_id is not None else ""
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT u.id AS user_id, u.nickname AS nickname,
|
||||
u.avatar_path AS avatar_path,
|
||||
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
{where}
|
||||
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
|
||||
"""
|
||||
params = {"gid": group_id} if group_id is not None else {}
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
return [_normalize(dict(r)) for r in result]
|
||||
def leaderboard(
|
||||
session: Session,
|
||||
group_id: int | None = None,
|
||||
*,
|
||||
history: list[RatedMatch] | None = None,
|
||||
rep: Replay | None = None,
|
||||
member_ids: set[int] | None = None,
|
||||
) -> dict:
|
||||
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и
|
||||
group_stats их переиспользуют).
|
||||
|
||||
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
|
||||
считается по всем партиям. С group_id игры, победы, винрейт и среднее место берутся
|
||||
только из партий группы (#80). member_ids — показывать только этих игроков (топ
|
||||
группы — её текущий состав, #76); места нумеруются уже после фильтра."""
|
||||
if history is None:
|
||||
history = load_history(session)
|
||||
if rep is None:
|
||||
rep = replay(history)
|
||||
shown = history if group_id is None else _for_group(history, group_id)
|
||||
|
||||
def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
||||
rows = _leaderboard_rows(session, group_id)
|
||||
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
|
||||
provisional = [r for r in rows if r["games"] < MIN_GAMES]
|
||||
qualified.sort(key=leaderboard_sort_key)
|
||||
provisional.sort(key=leaderboard_sort_key)
|
||||
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
||||
for m in shown:
|
||||
for s in m.seats:
|
||||
if member_ids is None or s.user_id in member_ids:
|
||||
by_user[s.user_id].append((m, s))
|
||||
users = (
|
||||
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
|
||||
if by_user
|
||||
else {}
|
||||
)
|
||||
|
||||
rows = []
|
||||
for uid, seats in by_user.items():
|
||||
u = users[uid]
|
||||
rows.append(
|
||||
{
|
||||
"user_id": uid,
|
||||
"nickname": u.nickname,
|
||||
**_summary(seats, rep.ratings[uid]),
|
||||
"rating": rep.ratings[uid], # только для сортировки
|
||||
"rating_confirmed": _rating_confirmed(rep, uid),
|
||||
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||||
}
|
||||
)
|
||||
qualified = sorted((r for r in rows if r["rating_confirmed"]), key=leaderboard_sort_key)
|
||||
provisional = sorted((r for r in rows if not r["rating_confirmed"]), key=leaderboard_sort_key)
|
||||
for i, r in enumerate(qualified, start=1):
|
||||
r["rank"] = i
|
||||
for r in provisional:
|
||||
r["rank"] = None
|
||||
for r in rows:
|
||||
del r["rating"]
|
||||
return {
|
||||
"entries": qualified,
|
||||
"provisional": provisional,
|
||||
@@ -104,150 +185,158 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
{cond}
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
r = session.execute(text(sql), params).mappings().first() or {}
|
||||
return {
|
||||
"games": int(r.get("games") or 0),
|
||||
"wins": int(r.get("wins") or 0),
|
||||
"win_rate": _round(r.get("win_rate") or 0.0, 4),
|
||||
"avg_place": _round(r.get("avg_place"), 2),
|
||||
"score": _round(r.get("score"), 1),
|
||||
def _faction_breakdown(
|
||||
session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int
|
||||
) -> list[dict]:
|
||||
by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
||||
for m, s in seats:
|
||||
by_faction[s.faction_id].append((m, s))
|
||||
if not by_faction:
|
||||
return []
|
||||
meta = {
|
||||
f.id: (f, code)
|
||||
for f, code in session.exec(
|
||||
select(Faction, Expansion.code)
|
||||
.join(Expansion, Expansion.id == Faction.expansion_id)
|
||||
.where(Faction.id.in_(list(by_faction)))
|
||||
).all()
|
||||
}
|
||||
|
||||
|
||||
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
e.code AS expansion_code,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN factions f ON f.id = s.faction_id
|
||||
JOIN expansions e ON e.id = f.expansion_id
|
||||
{cond}
|
||||
GROUP BY f.id, f.code, f.name_ru, e.code
|
||||
ORDER BY games DESC, score DESC
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
out = []
|
||||
for r in result:
|
||||
for fid, group in by_faction.items():
|
||||
faction, expansion_code = meta[fid]
|
||||
perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
|
||||
out.append(
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"expansion_code": r["expansion_code"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(r["avg_place"], 2),
|
||||
"score": _round(r["score"], 1),
|
||||
"faction_id": fid,
|
||||
"code": faction.code,
|
||||
"name_ru": faction.name_ru,
|
||||
"expansion_code": expansion_code,
|
||||
**{k: v for k, v in _summary(group, None).items() if k != "score"},
|
||||
# Средний результат относительно ожидания (S − E) × 100: насколько игрок
|
||||
# на фракции выступает выше рейтинговых шансов — без привязки к рейтингу.
|
||||
"score": round(perf * 100, 1),
|
||||
"name_ru_prepositional": faction_service.prepositional(
|
||||
faction.code, faction.name_ru
|
||||
),
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0)))
|
||||
return out
|
||||
|
||||
|
||||
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
|
||||
FROM scored s
|
||||
{cond}
|
||||
ORDER BY s.played_at DESC, s.match_id DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id, "lim": limit}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]:
|
||||
recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True)
|
||||
return [
|
||||
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
|
||||
for r in result
|
||||
{
|
||||
"place": s.place,
|
||||
"player_count": len(m.seats),
|
||||
"played_at": str(m.played_at),
|
||||
}
|
||||
for m, s in recent[:limit]
|
||||
]
|
||||
|
||||
|
||||
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
|
||||
overall = _overall_for_user(session, user_id, group_id)
|
||||
factions = _faction_breakdown(session, user_id, group_id)
|
||||
def _favorite_faction(session: Session, user_id: int) -> dict | None:
|
||||
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
|
||||
user = session.get(User, user_id)
|
||||
if user is None or user.favorite_faction_id is None:
|
||||
return None
|
||||
faction = session.get(Faction, user.favorite_faction_id)
|
||||
if faction is None:
|
||||
return None
|
||||
return {
|
||||
"id": faction.id,
|
||||
"code": faction.code,
|
||||
"name_ru": faction.name_ru,
|
||||
"expansion_id": faction.expansion_id,
|
||||
}
|
||||
|
||||
|
||||
def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
|
||||
return _summary(_user_seats(history, user_id), rep.ratings.get(user_id))
|
||||
|
||||
|
||||
def profile_stats(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
history: list[RatedMatch] | None = None,
|
||||
rep: Replay | None = None,
|
||||
) -> dict:
|
||||
"""Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
|
||||
переиспользует)."""
|
||||
if history is None:
|
||||
history = load_history(session)
|
||||
if rep is None:
|
||||
rep = replay(history)
|
||||
seats = _user_seats(history, user_id)
|
||||
factions = _faction_breakdown(session, seats, rep, user_id)
|
||||
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
||||
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
most_played = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
best = max(qualified, key=lambda f: f["score"]) if qualified else None
|
||||
worst = min(qualified, key=lambda f: f["score"]) if qualified else None
|
||||
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
|
||||
# рандомные раздачи.
|
||||
main = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall": overall,
|
||||
"overall": _summary(seats, rep.ratings.get(user_id)),
|
||||
"factions": factions,
|
||||
"best_faction": best,
|
||||
"worst_faction": worst,
|
||||
"most_played_faction": most_played,
|
||||
"recent_form": _recent_form(session, user_id, group_id),
|
||||
"favorite_faction": _favorite_faction(session, user_id),
|
||||
"main_faction": main,
|
||||
"recent_form": _recent_form(seats),
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
|
||||
def group_stats(session: Session, group_id: int) -> dict:
|
||||
board = leaderboard(session, group_id=group_id)
|
||||
total_matches = session.exec(
|
||||
select(Match).where(Match.group_id == group_id, Match.status == "finished")
|
||||
).all()
|
||||
last_at = None
|
||||
if total_matches:
|
||||
last_at = str(max(m.played_at for m in total_matches))
|
||||
history = load_history(session)
|
||||
rep = replay(history)
|
||||
group_history = _for_group(history, group_id)
|
||||
members = membership_service.list_members(session, group_id)
|
||||
# Список игроков группы — только её текущий состав: удалённый из группы в нём не висит.
|
||||
board = leaderboard(
|
||||
session,
|
||||
group_id,
|
||||
history=history,
|
||||
rep=rep,
|
||||
member_ids={u.id for _m, u in members}, # type: ignore[misc]
|
||||
)
|
||||
last_played = max((m.played_at for m in group_history), default=None)
|
||||
|
||||
games: dict[int, int] = defaultdict(int)
|
||||
wins: dict[int, int] = defaultdict(int)
|
||||
for m in group_history:
|
||||
for s in m.seats:
|
||||
games[s.faction_id] += 1
|
||||
wins[s.faction_id] += s.place == 1
|
||||
available_ids = group_service.available_faction_ids(session, group_id)
|
||||
faction_meta = []
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
|
||||
FROM factions f
|
||||
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
|
||||
GROUP BY f.id, f.code, f.name_ru
|
||||
ORDER BY games DESC, f.sort_order
|
||||
"""
|
||||
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
|
||||
for r in rows:
|
||||
faction_meta.append(
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"available": r["faction_id"] in available_ids,
|
||||
}
|
||||
)
|
||||
factions = sorted(
|
||||
session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index]
|
||||
)
|
||||
faction_meta = [
|
||||
{
|
||||
"faction_id": f.id,
|
||||
"code": f.code,
|
||||
"name_ru": f.name_ru,
|
||||
"games": games[f.id], # type: ignore[index]
|
||||
"wins": wins[f.id], # type: ignore[index]
|
||||
"available": f.id in available_ids,
|
||||
}
|
||||
for f in factions
|
||||
]
|
||||
|
||||
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
|
||||
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
|
||||
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
|
||||
played_ids = {e["user_id"] for e in board["entries"]} | {
|
||||
e["user_id"] for e in board["provisional"]
|
||||
}
|
||||
inactive = []
|
||||
for _m, u in membership_service.list_members(session, group_id):
|
||||
for _m, u in members:
|
||||
if u.id in played_ids:
|
||||
continue
|
||||
rating = rep.ratings.get(u.id) # type: ignore[arg-type]
|
||||
inactive.append(
|
||||
{
|
||||
"user_id": u.id,
|
||||
@@ -256,7 +345,8 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
"wins": 0,
|
||||
"win_rate": 0.0,
|
||||
"avg_place": None,
|
||||
"score": None,
|
||||
"score": None if rating is None else round(rating),
|
||||
"rating_confirmed": _rating_confirmed(rep, u.id), # type: ignore[arg-type]
|
||||
"rank": None,
|
||||
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||
}
|
||||
@@ -264,8 +354,8 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"total_matches": len(total_matches),
|
||||
"last_match_at": last_at,
|
||||
"total_matches": len(group_history),
|
||||
"last_match_at": str(last_played) if last_played else None,
|
||||
"leaderboard": board["entries"],
|
||||
"provisional": board["provisional"],
|
||||
"inactive": inactive,
|
||||
@@ -274,34 +364,50 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
|
||||
return {
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
"objectives": p.objectives,
|
||||
"worlds": p.worlds,
|
||||
}
|
||||
|
||||
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
|
||||
matches = session.exec(
|
||||
select(Match)
|
||||
.where(Match.group_id == group_id)
|
||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
|
||||
def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]:
|
||||
"""Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1)."""
|
||||
if not match_ids:
|
||||
return {}
|
||||
rows = session.exec(
|
||||
select(MatchParticipant, User, Faction)
|
||||
.join(User, User.id == MatchParticipant.user_id)
|
||||
.join(Faction, Faction.id == MatchParticipant.faction_id)
|
||||
.where(MatchParticipant.match_id.in_(match_ids))
|
||||
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
|
||||
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
|
||||
).all()
|
||||
out: dict[int, list[dict]] = {}
|
||||
for p, u, f in rows:
|
||||
out.setdefault(p.match_id, []).append(_participant_row(p, u, f))
|
||||
return out
|
||||
|
||||
|
||||
def _match_items(
|
||||
session: Session, matches, rating_deltas: dict[int, float] | None = None
|
||||
) -> list[dict]:
|
||||
"""Элементы списка партий (общее для списка группы и истории игрока).
|
||||
|
||||
rating_deltas — {match_id: ΔR} владельца истории; у списка группы их нет."""
|
||||
by_match = _participants_by_match(session, [m.id for m in matches])
|
||||
|
||||
items = []
|
||||
for m in matches:
|
||||
parts = []
|
||||
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
|
||||
parts.append(
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
)
|
||||
parts = by_match.get(m.id, [])
|
||||
items.append(
|
||||
{
|
||||
"id": m.id,
|
||||
@@ -315,15 +421,86 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
||||
"overall_comment": m.overall_comment,
|
||||
"created_by": m.created_by,
|
||||
"participants": parts,
|
||||
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||
return items
|
||||
|
||||
|
||||
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(select(func.count()).select_from(Match).where(*where)).one()
|
||||
matches = session.exec(
|
||||
select(Match)
|
||||
.where(*where)
|
||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return {
|
||||
"items": _match_items(session, matches),
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
def user_match_list(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
best_only: bool = False,
|
||||
) -> dict:
|
||||
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
||||
|
||||
У каждой партии — изменение общего рейтинга игрока за неё (rating_delta, один знак
|
||||
после запятой). best_only — одна лучшая партия: наибольший прирост рейтинга
|
||||
(учитывает и соперников, и ход партии); при равенстве берём более свежую."""
|
||||
history = load_history(session)
|
||||
rep = replay(history)
|
||||
mine = _user_seats(history, user_id)
|
||||
# + 0.0 превращает −0.0 (мелкий минус, округлённый до нуля) в обычный ноль.
|
||||
deltas = {m.id: round(rep.delta[(m.id, user_id)], 1) + 0.0 for m, _s in mine}
|
||||
if best_only:
|
||||
candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine]
|
||||
matches = [session.get(Match, max(candidates)[2])] if candidates else []
|
||||
return {
|
||||
"items": _match_items(session, matches, deltas),
|
||||
"total": len(matches),
|
||||
"limit": 1,
|
||||
"offset": 0,
|
||||
}
|
||||
|
||||
where = (
|
||||
MatchParticipant.user_id == user_id,
|
||||
Match.status == "finished",
|
||||
Match.id.in_(_playable_match_ids()),
|
||||
)
|
||||
total = session.exec(
|
||||
select(func.count())
|
||||
.select_from(Match)
|
||||
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||
.where(*where)
|
||||
).one()
|
||||
matches = session.exec(
|
||||
select(Match)
|
||||
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||
.where(*where)
|
||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return {
|
||||
"items": _match_items(session, matches, deltas),
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
|
||||
group_ids = list(
|
||||
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
|
||||
)
|
||||
@@ -335,21 +512,10 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
|
||||
.order_by(Match.started_at.desc(), Match.id.desc())
|
||||
).all()
|
||||
by_match = _participants_by_match(session, [m.id for m, _ in rows])
|
||||
out = []
|
||||
for m, gname in rows:
|
||||
parts = [
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
|
||||
]
|
||||
parts = by_match.get(m.id, [])
|
||||
out.append(
|
||||
{
|
||||
"id": m.id,
|
||||
@@ -364,8 +530,12 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
|
||||
|
||||
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
||||
board = leaderboard(session, group_id=None)
|
||||
profile = profile_stats(session, user_id, group_id=None)
|
||||
# История грузится и проигрывается один раз: из неё и топ, и профиль, и блок активной
|
||||
# группы (там игры и победы по группе, рейтинг — общий).
|
||||
history = load_history(session)
|
||||
rep = replay(history)
|
||||
board = leaderboard(session, history=history, rep=rep)
|
||||
profile = profile_stats(session, user_id, history=history, rep=rep)
|
||||
active_group_brief = None
|
||||
if active_group_id is not None:
|
||||
group = session.get(Group, active_group_id)
|
||||
@@ -373,7 +543,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
|
||||
active_group_brief = {
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
**_overall_for_user(session, user_id, active_group_id),
|
||||
**_overall(_for_group(history, active_group_id), rep, user_id),
|
||||
}
|
||||
return {
|
||||
"leaderboard": board["entries"][:leaderboard_limit],
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.password import validate_new_password
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||
from app.models import AuthIdentity, GroupMember, User
|
||||
from app.core.errors import (
|
||||
NicknameTakenError,
|
||||
NotFoundError,
|
||||
TelegramAlreadyLinkedError,
|
||||
TelegramTakenError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
from app.core.timeutil import utcnow
|
||||
from app.models import AuthIdentity, Faction, User
|
||||
|
||||
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
||||
_BIO_MAX = 500
|
||||
@@ -34,6 +42,28 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
|
||||
return existing is None or existing.id == exclude_user_id
|
||||
|
||||
|
||||
def search_users(session: Session, query: str, limit: int = 3) -> list[User]:
|
||||
"""Подсказки по нику: активные игроки (role='player'), без учёта регистра.
|
||||
|
||||
SQLite LIKE/lower() сворачивают регистр только для ASCII, а ники бывают
|
||||
кириллицей — поэтому выбираем активных игроков и фильтруем/ранжируем в
|
||||
Python (casefold). Пользователей десятки, полный проход дёшев; при росте
|
||||
до тысяч — заменить на индексированную колонку nickname_lower.
|
||||
Сначала префиксные совпадения, затем подстрочные, внутри — по алфавиту.
|
||||
"""
|
||||
q = (query or "").strip().casefold()
|
||||
if not q:
|
||||
return []
|
||||
users = session.exec(
|
||||
select(User).where(User.role == "player", User.is_active == True) # noqa: E712
|
||||
).all()
|
||||
matched = [u for u in users if q in u.nickname.casefold()]
|
||||
matched.sort(
|
||||
key=lambda u: (0 if u.nickname.casefold().startswith(q) else 1, u.nickname.casefold())
|
||||
)
|
||||
return matched[:limit]
|
||||
|
||||
|
||||
def nickname_format_ok(nickname: str) -> bool:
|
||||
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
||||
return bool(_NICK_RE.match((nickname or "").strip()))
|
||||
@@ -110,9 +140,73 @@ def register_from_identity(
|
||||
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:
|
||||
new_nickname = (new_nickname or "").strip()
|
||||
if not _NICK_RE.match(new_nickname):
|
||||
if not nickname_format_ok(new_nickname):
|
||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||
if not nickname_available(session, new_nickname, exclude_user_id=user.id):
|
||||
raise NicknameTakenError()
|
||||
@@ -125,12 +219,9 @@ def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||
|
||||
def set_active_group(session: Session, user: User, group_id: int | None) -> User:
|
||||
if group_id is not None:
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
from app.services import group_service # избегаем цикла импорта
|
||||
|
||||
if group_service.get_membership(session, group_id, user.id) is None:
|
||||
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
|
||||
user.active_group_id = group_id
|
||||
session.add(user)
|
||||
@@ -148,7 +239,13 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime |
|
||||
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
|
||||
if not avatar_path:
|
||||
return None
|
||||
version = int(updated_at.timestamp()) if updated_at else 0
|
||||
# В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает
|
||||
# его локальным, и версия разъезжалась с лидербордом, где то же поле считает SQL
|
||||
# (strftime('%s') читает его как UTC) — один аватар качался браузером дважды.
|
||||
version = 0
|
||||
if updated_at is not None:
|
||||
aware = updated_at if updated_at.tzinfo else updated_at.replace(tzinfo=timezone.utc)
|
||||
version = int(aware.timestamp())
|
||||
return f"/api/users/{user_id}/avatar?v={version}"
|
||||
|
||||
|
||||
@@ -163,6 +260,54 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
|
||||
return user
|
||||
|
||||
|
||||
_HISTORY_MODES = {"all", "best"}
|
||||
_HISTORY_DETAILS = {"compact", "full"}
|
||||
|
||||
|
||||
def update_history_prefs(
|
||||
session: Session, user: User, *, mode: str | None = None, detail: str | None = None
|
||||
) -> User:
|
||||
"""Витрина истории партий: что показывать и насколько подробно. None — не менять."""
|
||||
if mode is not None:
|
||||
if mode not in _HISTORY_MODES:
|
||||
raise ValidationError("Неизвестный режим истории партий.")
|
||||
user.history_mode = mode
|
||||
if detail is not None:
|
||||
if detail not in _HISTORY_DETAILS:
|
||||
raise ValidationError("Неизвестная подробность истории партий.")
|
||||
user.history_detail = detail
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
|
||||
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
|
||||
if faction_id is not None and session.get(Faction, faction_id) is None:
|
||||
raise ValidationError("Такой фракции не существует.")
|
||||
user.favorite_faction_id = faction_id
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def read_capped_image(file, max_bytes: int, limit_message: str) -> tuple[bytes, str]:
|
||||
"""Прочитать загруженный файл с ограничением размера и убедиться, что это картинка.
|
||||
|
||||
Читаем на байт больше лимита: так превышение видно, не загружая файл целиком.
|
||||
Один хелпер на все загрузки (аватар, фото партии, иконка ачивки) — иначе
|
||||
правка лимита или списка форматов расходится по четырём роутерам."""
|
||||
content = file.file.read(max_bytes + 1)
|
||||
if len(content) > max_bytes:
|
||||
raise ValidationError(limit_message)
|
||||
ext = sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
return content, ext
|
||||
|
||||
|
||||
def sniff_image_ext(content: bytes) -> str | None:
|
||||
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
@@ -198,6 +343,10 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
|
||||
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
|
||||
(Path(settings.upload_dir) / rel).write_bytes(content)
|
||||
user.avatar_path = rel
|
||||
# Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path
|
||||
# не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа
|
||||
# кэш-бастер остаётся прежним, и браузер час показывает старую картинку.
|
||||
user.updated_at = utcnow()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
@@ -212,6 +361,7 @@ def clear_avatar(session: Session, user: User) -> User:
|
||||
except OSError:
|
||||
pass
|
||||
user.avatar_path = None
|
||||
user.updated_at = utcnow()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
@@ -230,5 +380,5 @@ def public_profile(session: Session, user_id: int) -> dict:
|
||||
"nickname": user.nickname,
|
||||
"bio": user.bio,
|
||||
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||
"stats": stats_service.profile_stats(session, user_id, group_id=None),
|
||||
"stats": stats_service.profile_stats(session, user_id),
|
||||
}
|
||||
|
||||
+12
-2
@@ -9,6 +9,16 @@ python -m app.bootstrap
|
||||
|
||||
echo "[entrypoint] Запуск сервера…"
|
||||
# --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 сами не закрываются, и без лимита
|
||||
# остановка ждала бы их до 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 \
|
||||
--proxy-headers --forwarded-allow-ips="*"
|
||||
--proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10
|
||||
|
||||
@@ -18,7 +18,9 @@ from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: 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.token_revocation import revoked_tokens # noqa: E402
|
||||
from app.db.session import get_session # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||
@@ -48,6 +50,8 @@ def client(engine):
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session
|
||||
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
@@ -140,8 +144,15 @@ def finish_match(
|
||||
def create_finished_match(
|
||||
client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives"
|
||||
) -> dict:
|
||||
"""players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики)."""
|
||||
roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players]
|
||||
"""players: [{user_id, faction_id, place, comment?, was_random?}] → завершённая партия."""
|
||||
roster = [
|
||||
{
|
||||
"user_id": p["user_id"],
|
||||
"faction_id": p["faction_id"],
|
||||
"was_random": p.get("was_random", False),
|
||||
}
|
||||
for p in players
|
||||
]
|
||||
started = start_match(client, group_id, roster)
|
||||
assert started.status_code == 200, started.text
|
||||
results = [
|
||||
|
||||
@@ -111,3 +111,24 @@ def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path
|
||||
f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
|
||||
).status_code == 200
|
||||
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
|
||||
|
||||
|
||||
def test_delete_rejects_traversal_slug(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||
"""Slug из URL не должен уводить файловые операции за каталог ачивок.
|
||||
|
||||
Регрессия: `DELETE /api/admin/achievements/%2E%2E` снимал rmtree'ом родительскую
|
||||
папку каталога (в проде это /data — БД, uploads и ачивки разом)."""
|
||||
root = _use_tmp_achievements(monkeypatch, tmp_path / "achievements")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
sibling = tmp_path / "db.sqlite3"
|
||||
sibling.write_bytes(b"data")
|
||||
_admin(client, make_admin)
|
||||
|
||||
# Именно percent-кодированная форма: обычные точки httpx нормализует ещё до
|
||||
# отправки, запрос уходит на /api/admin/ и до обработчика вовсе не доходит.
|
||||
r = client.request(
|
||||
"DELETE", "/api/admin/achievements/%2E%2E", headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND" # ответ обработчика, а не промах роутинга
|
||||
assert sibling.exists() and root.is_dir()
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from app.models import Faction
|
||||
from app.seed.reference_data import FACTIONS, seed_reference_data
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||
|
||||
|
||||
@@ -203,3 +207,106 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
|
||||
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
|
||||
assert ap["faction_name"] == "Орки WAAAGH"
|
||||
|
||||
|
||||
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
|
||||
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
|
||||
имя, заданное админом (#72)."""
|
||||
login(client, "Кто-то")
|
||||
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
|
||||
_admin_login(client, make_admin)
|
||||
r = client.patch(
|
||||
f"/api/admin/factions/{orks['id']}",
|
||||
json={"name_ru": "Орки WAAAGH"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
with Session(engine) as s:
|
||||
seed_reference_data(s) # то же, что делает рестарт
|
||||
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
|
||||
|
||||
|
||||
def test_seeding_is_complete_and_idempotent():
|
||||
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
|
||||
engine = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as s:
|
||||
seed_reference_data(s)
|
||||
seed_reference_data(s)
|
||||
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
|
||||
assert names == {code: name for code, name, _exp, _order in FACTIONS}
|
||||
|
||||
|
||||
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
||||
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
||||
|
||||
Регрессия: matches.group_id — ON DELETE RESTRICT, и голый session.delete ронял
|
||||
IntegrityError наружу пятисоткой без конверта ошибки."""
|
||||
me = login(client, "Owner")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, 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()]
|
||||
create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
_admin_login(client, make_admin)
|
||||
r = client.delete(f"/api/admin/groups/{gid}", headers=csrf_headers(client))
|
||||
assert r.status_code == 409, r.text
|
||||
# Важен не только код ответа: группа и её партии должны пережить отказ.
|
||||
assert any(g["id"] == gid for g in client.get("/api/admin/groups").json())
|
||||
assert any(m["group_id"] == gid for m in client.get("/api/admin/matches").json())
|
||||
|
||||
|
||||
def test_last_owner_cannot_demote_self(client: TestClient, engine):
|
||||
"""Единственный владелец не может разжаловать сам себя.
|
||||
|
||||
Регрессия: change_role не проверял последнего владельца (в отличие от удаления),
|
||||
и группа оставалась без владельца навсегда — назначить нового было некому."""
|
||||
me = login(client, "Owner")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
add_group_member(engine, gid, "Игрок2")
|
||||
|
||||
r = client.patch(
|
||||
f"/api/groups/{gid}/members/{me['id']}",
|
||||
json={"role": "member"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
members = client.get(f"/api/groups/{gid}/members").json()
|
||||
assert any(m["user_id"] == me["id"] and m["role"] == "owner" for m in members)
|
||||
|
||||
|
||||
def test_ownership_transfer_still_works(client: TestClient, engine):
|
||||
"""Обратная сторона защиты последнего владельца: передать роль по-прежнему можно."""
|
||||
me = login(client, "Owner")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
p2 = add_group_member(engine, gid, "Игрок2")
|
||||
|
||||
promote = client.patch(
|
||||
f"/api/groups/{gid}/members/{p2}", json={"role": "owner"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert promote.status_code == 200, promote.text
|
||||
|
||||
# Владельцев теперь двое — прежний может сложить полномочия.
|
||||
demote = client.patch(
|
||||
f"/api/groups/{gid}/members/{me['id']}",
|
||||
json={"role": "member"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert demote.status_code == 200, demote.text
|
||||
members = client.get(f"/api/groups/{gid}/members").json()
|
||||
assert [m["user_id"] for m in members if m["role"] == "owner"] == [p2]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Ротация пароля администратора из .env (#73): `python -m app.bootstrap --reset-admin-password`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app import bootstrap
|
||||
from app.core.security import verify_password
|
||||
from app.models import User
|
||||
from tests.conftest import csrf_headers
|
||||
|
||||
OLD, NEW = "secret123", "brand-new-admin-pw"
|
||||
|
||||
|
||||
def _admin(engine) -> User:
|
||||
with Session(engine) as s:
|
||||
return s.exec(select(User).where(User.role == "admin")).one()
|
||||
|
||||
|
||||
def _login(client: TestClient, password: str):
|
||||
return client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": password},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
|
||||
|
||||
def test_reset_applies_env_password_and_revokes_sessions(
|
||||
client: TestClient, engine, make_admin, monkeypatch
|
||||
):
|
||||
make_admin("admin", OLD)
|
||||
assert _login(client, OLD).status_code == 200
|
||||
assert client.get("/api/admin/me").status_code == 200
|
||||
version = _admin(engine).token_version
|
||||
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s:
|
||||
bootstrap.reset_admin_password(s)
|
||||
|
||||
admin = _admin(engine)
|
||||
assert verify_password(NEW, admin.password_hash)
|
||||
assert admin.token_version == version + 1
|
||||
# Прежняя админская cookie больше не действует (в т.ч. у того, кто украл пароль).
|
||||
assert client.get("/api/admin/me").status_code == 401
|
||||
assert _login(client, OLD).status_code == 401
|
||||
assert _login(client, NEW).status_code == 200
|
||||
|
||||
|
||||
def test_regular_bootstrap_keeps_admin_password_outside_dev(engine, make_admin, monkeypatch):
|
||||
"""Обычный старт в prod по-прежнему не берёт пароль из .env — только ротация."""
|
||||
make_admin("admin", OLD)
|
||||
monkeypatch.setattr(bootstrap.settings, "app_env", "production")
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s:
|
||||
bootstrap._ensure_admin(s)
|
||||
assert verify_password(OLD, _admin(engine).password_hash)
|
||||
|
||||
|
||||
def test_reset_refuses_without_admin_or_password(engine, make_admin, monkeypatch):
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s, pytest.raises(RuntimeError, match="Администратора ещё нет"):
|
||||
bootstrap.reset_admin_password(s)
|
||||
|
||||
make_admin("admin", OLD)
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", " ")
|
||||
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_PASSWORD пуст"):
|
||||
bootstrap.reset_admin_password(s)
|
||||
assert verify_password(OLD, _admin(engine).password_hash)
|
||||
|
||||
|
||||
def test_reset_refuses_when_bootstrap_disabled(engine, make_admin, monkeypatch):
|
||||
make_admin("admin", OLD)
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_bootstrap_enabled", False)
|
||||
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_BOOTSTRAP_ENABLED"):
|
||||
bootstrap.reset_admin_password(s)
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Объявления администрации (#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"]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Хардненинг 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
|
||||
@@ -138,3 +138,23 @@ def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_pat
|
||||
login(client, "Чужак") # не состоит в группе
|
||||
g = client.get(f"/api/matches/{mid}/attachments/{aid}")
|
||||
assert g.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_attachment_upload_moves_version(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
"""Вложения видны в MatchRead, но строку matches не трогают.
|
||||
|
||||
Регрессия: из-за этого версия партии не двигалась, и правка со старой версией
|
||||
проходила мимо оптимистичной блокировки."""
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
v1 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||
|
||||
assert _upload(client, mid).status_code == 200
|
||||
v2 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||
assert v2 != v1
|
||||
|
||||
stale = client.delete(
|
||||
f"/api/matches/{mid}", params={"expected_version": v1}, headers=csrf_headers(client)
|
||||
)
|
||||
assert stale.status_code == 409, stale.text
|
||||
assert stale.json()["error"]["code"] == "STALE_WRITE"
|
||||
|
||||
@@ -10,8 +10,9 @@ from fastapi.testclient import TestClient
|
||||
from tests.conftest import csrf_headers
|
||||
|
||||
|
||||
def test_auth_config_dev_has_both_methods(client: TestClient):
|
||||
def test_auth_config_dev_has_all_methods(client: TestClient):
|
||||
cfg = client.get("/api/auth/config").json()
|
||||
assert "password" in cfg["methods"]
|
||||
assert "telegram" in cfg["methods"]
|
||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||
|
||||
@@ -21,15 +22,13 @@ def test_enabled_methods_by_env(monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
assert set(enabled_methods()) == {"telegram", "stub"}
|
||||
monkeypatch.setattr(settings, "app_env", "test")
|
||||
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
|
||||
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
assert enabled_methods() == ["telegram"] # prod → только Telegram
|
||||
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
||||
|
||||
|
||||
def test_env_flags_and_db_path(monkeypatch):
|
||||
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
|
||||
"""dev → файл дева; prod → том /data."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
|
||||
@@ -37,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
|
||||
monkeypatch.setattr(settings, "app_env", "test")
|
||||
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
|
||||
|
||||
|
||||
@@ -75,3 +75,39 @@ def test_stale_finish_rejected(client: TestClient, engine):
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text
|
||||
|
||||
|
||||
def test_participant_edit_moves_version(client: TestClient, engine):
|
||||
"""Правка одних участников тоже двигает версию партии.
|
||||
|
||||
Регрессия: updated_at менялся только при UPDATE строки matches, поэтому после
|
||||
правки участников версия оставалась прежней и вторая правка со старой версией
|
||||
проходила вместо 409 — ровно то, от чего защищает блокировка."""
|
||||
me, p2, mid = _start(client, engine)
|
||||
fin = finish_match(
|
||||
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
|
||||
win_reason="objectives",
|
||||
)
|
||||
assert fin.status_code == 200, fin.text
|
||||
v1 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||
|
||||
parts = {p["user_id"]: p["faction_id"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
|
||||
swap = [
|
||||
{"user_id": me["id"], "faction_id": parts[me["id"]], "place": 2},
|
||||
{"user_id": p2, "faction_id": parts[p2], "place": 1},
|
||||
]
|
||||
r = client.patch(
|
||||
f"/api/matches/{mid}",
|
||||
json={"participants": swap, "expected_version": v1},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get(f"/api/matches/{mid}").json()["version"] != v1
|
||||
|
||||
stale = client.patch(
|
||||
f"/api/matches/{mid}",
|
||||
json={"participants": swap, "expected_version": v1},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert stale.status_code == 409, stale.text
|
||||
assert stale.json()["error"]["code"] == "STALE_WRITE"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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
|
||||
@@ -0,0 +1,150 @@
|
||||
"""CSRF double-submit: токен восстанавливается, если сессия пережила cookie csrf_token,
|
||||
а сама проверка мутаций остаётся такой же строгой."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import csrf_headers, login
|
||||
|
||||
|
||||
def _set_cookie(resp, name: str) -> str | None:
|
||||
"""Заголовок Set-Cookie для cookie name (или None, если ответ её не ставит)."""
|
||||
for header in resp.headers.get_list("set-cookie"):
|
||||
if header.startswith(f"{name}="):
|
||||
return header
|
||||
return None
|
||||
|
||||
|
||||
def _max_age(set_cookie: str) -> int:
|
||||
for part in set_cookie.split(";"):
|
||||
key, _, value = part.strip().partition("=")
|
||||
if key.lower() == "max-age":
|
||||
return int(value)
|
||||
raise AssertionError(f"нет Max-Age: {set_cookie}")
|
||||
|
||||
|
||||
def test_missing_token_reissued_on_safe_request(client: TestClient):
|
||||
"""Сессия жива, csrf_token истёк → первый же GET отдаёт новый токен, мутации проходят."""
|
||||
login(client, "Игрок")
|
||||
client.cookies.delete("csrf_token")
|
||||
|
||||
me = client.get("/api/users/me")
|
||||
assert me.status_code == 200, me.text
|
||||
assert _set_cookie(me, "csrf_token") is not None
|
||||
assert client.cookies.get("csrf_token")
|
||||
|
||||
r = client.patch(
|
||||
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_rejected_mutation_reissues_token(client: TestClient):
|
||||
"""Отказ CSRF без cookie сам выдаёт токен: иначе не выйти и не перезайти."""
|
||||
login(client, "Игрок")
|
||||
client.cookies.delete("csrf_token")
|
||||
|
||||
r = client.post("/api/auth/logout")
|
||||
assert r.status_code == 403
|
||||
assert r.json()["error"]["code"] == "CSRF_FAILED"
|
||||
assert _set_cookie(r, "csrf_token") is not None
|
||||
|
||||
r2 = client.post("/api/auth/logout", headers=csrf_headers(client))
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
|
||||
def test_admin_login_does_not_shorten_token(client: TestClient, make_admin):
|
||||
"""Вход в админку перезаписывает общий csrf_token — срок не короче сессии игрока."""
|
||||
r_user = client.post("/api/auth/dev/login", json={"nickname": "Игрок"})
|
||||
session_age = _max_age(_set_cookie(r_user, "fs_session"))
|
||||
|
||||
make_admin("boss", "secret123")
|
||||
r_admin = client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "boss", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r_admin.status_code == 200, r_admin.text
|
||||
assert _max_age(_set_cookie(r_admin, "csrf_token")) >= session_age
|
||||
|
||||
|
||||
def test_check_is_not_weakened(client: TestClient):
|
||||
"""Cookie есть, заголовка нет или он чужой — 403, и токен при этом не перевыдаётся."""
|
||||
login(client, "Игрок")
|
||||
token = client.cookies.get("csrf_token")
|
||||
body = {"favorite_faction_id": None}
|
||||
|
||||
no_header = client.patch("/api/users/me/profile", json=body)
|
||||
assert no_header.status_code == 403
|
||||
assert _set_cookie(no_header, "csrf_token") is None
|
||||
|
||||
wrong = client.patch("/api/users/me/profile", json=body, headers={"X-CSRF-Token": "forged"})
|
||||
assert wrong.status_code == 403
|
||||
assert _set_cookie(wrong, "csrf_token") is None
|
||||
|
||||
assert client.cookies.get("csrf_token") == token
|
||||
|
||||
|
||||
def test_anonymous_gets_no_token(client: TestClient):
|
||||
r = client.get("/api/auth/config")
|
||||
assert r.status_code == 200
|
||||
assert _set_cookie(r, "csrf_token") is None
|
||||
|
||||
|
||||
def _run_middleware(app_messages: list[dict], cookie: bytes) -> list[dict]:
|
||||
"""Прогоняет CSRFMiddleware над фейковым приложением и возвращает отправленное."""
|
||||
from app.main import CSRFMiddleware
|
||||
|
||||
async def fake_app(scope, receive, send): # noqa: ANN001
|
||||
for message in app_messages:
|
||||
await send(message)
|
||||
|
||||
sent: list[dict] = []
|
||||
|
||||
async def send(message): # noqa: ANN001
|
||||
sent.append(message)
|
||||
|
||||
async def receive(): # pragma: no cover — фейковому приложению тело запроса не нужно
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/events",
|
||||
"raw_path": b"/api/events",
|
||||
"root_path": "",
|
||||
"scheme": "http",
|
||||
"server": ("testserver", 80),
|
||||
"query_string": b"",
|
||||
"headers": [(b"cookie", cookie)],
|
||||
}
|
||||
asyncio.run(CSRFMiddleware(fake_app)(scope, receive, send))
|
||||
return sent
|
||||
|
||||
|
||||
def test_reissue_keeps_stream_unbuffered():
|
||||
"""Перевыдача трогает только стартовое сообщение: чанки SSE идут по одному, без склейки."""
|
||||
start = {"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/event-stream")]}
|
||||
chunks = [
|
||||
{"type": "http.response.body", "body": b": connected\n\n", "more_body": True},
|
||||
{"type": "http.response.body", "body": b": ping\n\n", "more_body": True},
|
||||
{"type": "http.response.body", "body": b"", "more_body": False},
|
||||
]
|
||||
sent = _run_middleware([start, *chunks], cookie=b"fs_session=abc")
|
||||
|
||||
assert sent[1:] == chunks
|
||||
cookies = [v for k, v in sent[0]["headers"] if k == b"set-cookie"]
|
||||
assert len(cookies) == 1 and cookies[0].startswith(b"csrf_token=")
|
||||
|
||||
|
||||
def test_reissue_does_not_duplicate_app_cookie():
|
||||
"""Если приложение само ставит csrf_token (вход), второй Set-Cookie не дописывается."""
|
||||
own = (b"set-cookie", b"csrf_token=from-app; Path=/")
|
||||
start = {"type": "http.response.start", "status": 200, "headers": [own]}
|
||||
body = {"type": "http.response.body", "body": b"{}", "more_body": False}
|
||||
sent = _run_middleware([start, body], cookie=b"fs_session=abc")
|
||||
|
||||
cookies = [v for k, v in sent[0]["headers"] if k == b"set-cookie"]
|
||||
assert cookies == [own[1]]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
|
||||
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
|
||||
from app.core import events
|
||||
|
||||
published: list[tuple[list[int], dict]] = []
|
||||
monkeypatch.setattr(
|
||||
events.hub, "publish", lambda ids, event: published.append((list(ids), event))
|
||||
)
|
||||
return published
|
||||
|
||||
|
||||
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
|
||||
return {uid for ids, e in published if e.get("type") == kind for uid in ids}
|
||||
|
||||
|
||||
def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
|
||||
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
|
||||
me = login(client, "Хост")
|
||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||
|
||||
def group(name: str) -> int:
|
||||
return client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
|
||||
gid, other_gid = group("Группа"), group("Другая")
|
||||
p2 = add_group_member(engine, gid, "Игрок2")
|
||||
outsider = add_group_member(engine, other_gid, "Чужой")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return me, gid, p2, outsider, fids
|
||||
|
||||
|
||||
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
|
||||
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||
|
||||
published = _capture_events(monkeypatch)
|
||||
started = start_match(
|
||||
client, gid,
|
||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
|
||||
assert _recipients(published, "match") == {me["id"], p2}
|
||||
assert _recipients(published, "ratings") == set()
|
||||
|
||||
published.clear()
|
||||
fin = finish_match(
|
||||
client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||
)
|
||||
assert fin.status_code == 200, fin.text
|
||||
assert _recipients(published, "match") == {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(
|
||||
client: TestClient, engine, monkeypatch
|
||||
):
|
||||
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
|
||||
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||
mid = start_match(
|
||||
client, gid,
|
||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||
).json()["id"]
|
||||
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
||||
|
||||
published = _capture_events(monkeypatch)
|
||||
version = client.get(f"/api/matches/{mid}").json()["version"]
|
||||
r = client.delete(
|
||||
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _recipients(published, "match") == {me["id"], p2}
|
||||
assert outsider in _recipients(published, "ratings")
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Черновик формы завершения: совместное заполнение результатов партии.
|
||||
|
||||
Плюс запрет правки незавершённой партии (места без завершения — «результат есть,
|
||||
а игры как бы не было»)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _start(client: TestClient, engine) -> tuple[dict, int, int, int]:
|
||||
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()]
|
||||
started = start_match(
|
||||
client, gid,
|
||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
return me, gid, p2, started.json()["id"]
|
||||
|
||||
|
||||
def _draft(client: TestClient, mid: int, body: dict):
|
||||
return client.put(f"/api/matches/{mid}/finish-draft", json=body, headers=csrf_headers(client))
|
||||
|
||||
|
||||
def test_draft_is_shared_between_participants(client: TestClient, engine):
|
||||
"""Второй участник видит раскладку первого, не перезагружая страницу."""
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
|
||||
r = _draft(client, mid, {
|
||||
"blocks": [[me["id"]], [p2]],
|
||||
"eliminated": [],
|
||||
"comments": {str(p2): "почти успел"},
|
||||
"win_reason": "worlds",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["finish_draft"]["data"]["blocks"] == [[me["id"]], [p2]]
|
||||
|
||||
login(client, "Игрок2")
|
||||
seen = client.get(f"/api/matches/{mid}").json()["finish_draft"]
|
||||
assert seen["data"]["blocks"] == [[me["id"]], [p2]]
|
||||
assert seen["data"]["comments"][str(p2)] == "почти успел"
|
||||
assert seen["data"]["win_reason"] == "worlds"
|
||||
assert seen["updated_by_nickname"] == "Хост"
|
||||
|
||||
|
||||
def test_draft_does_not_move_match_version(client: TestClient, engine):
|
||||
"""Черновик не трогает версию партии.
|
||||
|
||||
Иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую
|
||||
правку — ровно то, ради чего черновик и делался."""
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
v1 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||
|
||||
assert _draft(client, mid, {"blocks": [[p2], [me["id"]]]}).status_code == 200
|
||||
assert client.get(f"/api/matches/{mid}").json()["version"] == v1
|
||||
|
||||
# И завершение со «старой» (на деле актуальной) версией проходит.
|
||||
fin = finish_match(
|
||||
client, mid, [{"user_id": p2, "place": 1}, {"user_id": me["id"], "place": 2}],
|
||||
)
|
||||
assert fin.status_code == 200, fin.text
|
||||
|
||||
|
||||
def test_draft_cleared_after_finish(client: TestClient, engine):
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 200
|
||||
|
||||
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
||||
assert client.get(f"/api/matches/{mid}").json()["finish_draft"] is None
|
||||
|
||||
# В завершённую партию черновик не пишется.
|
||||
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 409
|
||||
|
||||
|
||||
def test_draft_rejects_outsider_and_foreign_players(client: TestClient, engine):
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
stranger = login(client, "Чужак") # в группе не состоит
|
||||
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 403
|
||||
|
||||
login(client, "Хост")
|
||||
bad = _draft(client, mid, {"blocks": [[me["id"]], [stranger["id"]]]})
|
||||
assert bad.status_code == 422, bad.text
|
||||
|
||||
|
||||
def test_in_progress_match_cannot_be_patched(client: TestClient, engine):
|
||||
"""Места и причина победы у идущей партии — только через завершение.
|
||||
|
||||
Иначе партия остаётся in_progress с проставленными местами: висит в
|
||||
«Незавершённых», но в статистику не попадает и очков не приносит."""
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
parts = client.get(f"/api/matches/{mid}").json()["participants"]
|
||||
body = {
|
||||
"participants": [
|
||||
{"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1}
|
||||
for i, p in enumerate(parts)
|
||||
],
|
||||
"win_reason": "objectives",
|
||||
}
|
||||
r = client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
|
||||
assert r.status_code == 409, r.text
|
||||
assert client.get(f"/api/matches/{mid}").json()["status"] == "in_progress"
|
||||
|
||||
|
||||
def test_admin_cannot_patch_in_progress_match(client: TestClient, engine, make_admin):
|
||||
me, gid, p2, mid = _start(client, engine)
|
||||
parts = client.get(f"/api/matches/{mid}").json()["participants"]
|
||||
|
||||
make_admin("admin", "secret123")
|
||||
assert client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
).status_code == 200
|
||||
body = {
|
||||
"participants": [
|
||||
{"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1}
|
||||
for i, p in enumerate(parts)
|
||||
],
|
||||
"win_reason": "objectives",
|
||||
}
|
||||
r = client.patch(f"/api/admin/matches/{mid}", json=body, headers=csrf_headers(client))
|
||||
assert r.status_code == 409, r.text
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Список игроков группы — только текущий состав (#76).
|
||||
|
||||
Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в
|
||||
истории: они уже повлияли на (общий) рейтинг оставшихся."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
|
||||
def _ids(stats: dict) -> dict[str, set[int]]:
|
||||
return {
|
||||
block: {e["user_id"] for e in stats[block]}
|
||||
for block in ("leaderboard", "provisional", "inactive")
|
||||
}
|
||||
|
||||
|
||||
def _group(client: TestClient) -> tuple[int, list[int]]:
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return gid, fids
|
||||
|
||||
|
||||
def _remove(client: TestClient, gid: int, uid: int) -> None:
|
||||
r = client.delete(f"/api/groups/{gid}/members/{uid}", headers=csrf_headers(client))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_removed_member_leaves_group_rating_but_not_overall(client: TestClient, engine):
|
||||
me = login(client, "Хозяин")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Ушедший")
|
||||
create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||
{"user_id": b, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
before = client.get(f"/api/groups/{gid}/stats").json()
|
||||
my_score = next(e["score"] for e in before["provisional"] if e["user_id"] == me["id"])
|
||||
|
||||
_remove(client, gid, b)
|
||||
|
||||
stats = client.get(f"/api/groups/{gid}/stats").json()
|
||||
ids = _ids(stats)
|
||||
assert all(b not in block for block in ids.values())
|
||||
assert me["id"] in ids["provisional"]
|
||||
# Партия с ушедшим учтена: рейтинг оставшегося не изменился, счётчик партий тоже.
|
||||
assert next(e["score"] for e in stats["provisional"] if e["user_id"] == me["id"]) == my_score
|
||||
assert stats["total_matches"] == 1
|
||||
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
assert b in {e["user_id"] for e in board["entries"] + board["provisional"]}
|
||||
|
||||
# Вернули в группу — снова в списке со своей историей.
|
||||
add_group_member(engine, gid, "Ушедший")
|
||||
back = client.get(f"/api/groups/{gid}/stats").json()
|
||||
entry = next(e for e in back["provisional"] if e["user_id"] == b)
|
||||
assert (entry["games"], entry["score"]) == (1, 1468)
|
||||
|
||||
|
||||
def test_group_ranks_renumbered_after_removal(client: TestClient, engine):
|
||||
me = login(client, "Первый")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Второй")
|
||||
c = add_group_member(engine, gid, "Третий")
|
||||
for _ in range(10):
|
||||
create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||
{"user_id": b, "faction_id": fids[1], "place": 2},
|
||||
{"user_id": c, "faction_id": fids[2], "place": 3},
|
||||
],
|
||||
)
|
||||
assert [e["rank"] for e in client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]] == [1, 2, 3]
|
||||
|
||||
_remove(client, gid, b)
|
||||
|
||||
board = client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]
|
||||
assert [(e["user_id"], e["rank"]) for e in board] == [(me["id"], 1), (c, 2)]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Правка завершённой партии игроком: история чинится после изменений в группе."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
|
||||
def _exp_id(client: TestClient, code: str) -> int:
|
||||
return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code)
|
||||
|
||||
|
||||
def _played(client: TestClient, engine) -> tuple[dict, int, int, int, dict]:
|
||||
"""Партия «Аня против Бори» в группе с обоими дополнениями."""
|
||||
me = login(client, "Аня")
|
||||
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]},
|
||||
headers=csrf_headers(client),
|
||||
).json()["id"]
|
||||
b = add_group_member(engine, gid, "Боря")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||
{"user_id": b, "faction_id": factions["tau"], "place": 2},
|
||||
],
|
||||
)["id"]
|
||||
return me, gid, b, mid, factions
|
||||
|
||||
|
||||
def _swap_places(client: TestClient, mid: int, me_id: int, b: int, factions: dict) -> dict:
|
||||
detail = client.get(f"/api/matches/{mid}").json()
|
||||
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||
return {
|
||||
"participants": [
|
||||
{"user_id": me_id, "faction_id": fid[me_id], "place": 2},
|
||||
{"user_id": b, "faction_id": fid[b], "place": 1},
|
||||
],
|
||||
"expected_version": detail["version"],
|
||||
}
|
||||
|
||||
|
||||
def test_edit_after_expansion_disabled(client: TestClient, engine):
|
||||
"""Дополнение выключили — партия на Тау всё равно правится.
|
||||
|
||||
Регрессия: правка проверяла фракции по ТЕКУЩЕМУ набору группы, и партия,
|
||||
сыгранная на фракции из отключённого дополнения, становилась неисправимой."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
|
||||
off = client.put(
|
||||
f"/api/groups/{gid}/expansions",
|
||||
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert off.status_code == 200, off.text
|
||||
|
||||
r = client.patch(
|
||||
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
places = {p["user_id"]: p["place"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
|
||||
assert places[b] == 1 and places[me["id"]] == 2
|
||||
|
||||
|
||||
def test_edit_after_player_left_group(client: TestClient, engine):
|
||||
"""Игрока убрали из группы — партия с ним всё равно правится."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
|
||||
out = client.delete(f"/api/groups/{gid}/members/{b}", headers=csrf_headers(client))
|
||||
assert out.status_code == 200, out.text
|
||||
|
||||
r = client.patch(
|
||||
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_edit_rejects_new_outsider_and_unavailable_faction(client: TestClient, engine):
|
||||
"""Послабление — только для того, что уже в партии.
|
||||
|
||||
Вписать нового игрока не из группы или фракцию, которой в партии не было и у
|
||||
группы нет, по-прежнему нельзя: иначе в историю можно занести что угодно."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
stranger = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Чужак"}, headers=csrf_headers(client)
|
||||
).json()
|
||||
detail = client.get(f"/api/matches/{mid}").json()
|
||||
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||
|
||||
bad_user = client.patch(
|
||||
f"/api/matches/{mid}",
|
||||
json={
|
||||
"participants": [
|
||||
{"user_id": me["id"], "faction_id": fid[me["id"]], "place": 1},
|
||||
{"user_id": stranger["id"], "faction_id": fid[b], "place": 2},
|
||||
]
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert bad_user.status_code == 422, bad_user.text
|
||||
|
||||
# Выключаем дополнение и пробуем поставить ЕГО фракцию, которой в партии не было.
|
||||
assert client.put(
|
||||
f"/api/groups/{gid}/expansions",
|
||||
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||
headers=csrf_headers(client),
|
||||
).status_code == 200
|
||||
bad_faction = client.patch(
|
||||
f"/api/matches/{mid}",
|
||||
json={
|
||||
"participants": [
|
||||
{"user_id": me["id"], "faction_id": factions["necrons"], "place": 1},
|
||||
{"user_id": b, "faction_id": fid[b], "place": 2},
|
||||
]
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert bad_faction.status_code == 422, bad_faction.text
|
||||
|
||||
|
||||
def test_create_match_still_validated(client: TestClient, engine):
|
||||
"""Создание партии не ослабло: посторонний игрок по-прежнему отклоняется."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
stranger = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Чужак2"}, headers=csrf_headers(client)
|
||||
).json()
|
||||
r = client.post(
|
||||
"/api/matches",
|
||||
json={
|
||||
"group_id": gid,
|
||||
"participants": [
|
||||
{"user_id": me["id"], "faction_id": factions["orks"]},
|
||||
{"user_id": stranger["id"], "faction_id": factions["eldar"]},
|
||||
],
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
@@ -56,23 +56,49 @@ def test_create_list_mark_read(engine):
|
||||
assert after["items"][0]["read_at"] is not None
|
||||
|
||||
|
||||
def test_purge_expired(engine):
|
||||
def test_purge_removes_only_long_read(engine):
|
||||
"""Протухают лишь ПРОЧИТАННЫЕ (спустя 72ч после прочтения): непрочитанное должно
|
||||
дождаться игрока, сколько бы тот ни отсутствовал."""
|
||||
uid = _make_user(engine, "Боб")
|
||||
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
|
||||
hours=notification_service.RETENTION_HOURS + 1
|
||||
)
|
||||
with Session(engine) as s:
|
||||
n = Notification(user_id=uid, type="test", title="Старое")
|
||||
s.add(n)
|
||||
s.commit()
|
||||
s.refresh(n)
|
||||
# Состарим запись за пределы окна хранения.
|
||||
n.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
|
||||
hours=notification_service.RETENTION_HOURS + 1
|
||||
# Старое непрочитанное, старое давно прочитанное и свежепрочитанное.
|
||||
s.add(Notification(user_id=uid, type="test", title="Непрочитанное", created_at=old))
|
||||
s.add(
|
||||
Notification(
|
||||
user_id=uid, type="test", title="Давно прочитанное", created_at=old, read_at=old
|
||||
)
|
||||
)
|
||||
s.add(
|
||||
Notification(
|
||||
user_id=uid,
|
||||
type="test",
|
||||
title="Свежепрочитанное",
|
||||
created_at=old,
|
||||
read_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
s.add(n)
|
||||
s.commit()
|
||||
|
||||
removed = notification_service.purge_expired(s)
|
||||
assert removed == 1
|
||||
assert notification_service.list_for_user(s, uid)["items"] == []
|
||||
titles = {n["title"] for n in notification_service.list_for_user(s, uid)["items"]}
|
||||
assert titles == {"Непрочитанное", "Свежепрочитанное"}
|
||||
|
||||
|
||||
def test_cap_per_user(engine):
|
||||
"""Кап MAX_PER_USER: при создании сверх лимита удаляются старейшие."""
|
||||
uid = _make_user(engine, "Копитель")
|
||||
with Session(engine) as s:
|
||||
for i in range(notification_service.MAX_PER_USER + 5):
|
||||
notification_service.create(s, uid, type="test", title=f"n{i}")
|
||||
data = notification_service.list_for_user(s, uid)
|
||||
assert len(data["items"]) == notification_service.MAX_PER_USER
|
||||
titles = {n["title"] for n in data["items"]}
|
||||
assert "n0" not in titles # старейшие срезаны
|
||||
assert f"n{notification_service.MAX_PER_USER + 4}" in titles # новейшее на месте
|
||||
|
||||
|
||||
def test_notifications_require_auth(client: TestClient):
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
||||
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)
|
||||
@@ -1,9 +1,18 @@
|
||||
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
|
||||
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар
|
||||
(загрузка/отдача/удаление), публичный профиль."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
from app.models import User
|
||||
from tests.conftest import (
|
||||
add_group_member,
|
||||
create_finished_match,
|
||||
csrf_headers,
|
||||
login,
|
||||
start_match,
|
||||
)
|
||||
|
||||
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
@@ -124,3 +133,318 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
|
||||
|
||||
|
||||
def test_main_faction_counts_random_games(client: TestClient, 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, "Партнёр")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
|
||||
# Дважды играл фракцией A по рандому, один раз фракцией B — по своему выбору.
|
||||
for _ in range(2):
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[2], "place": 1},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
# Условия выбора не важны: A сыграна чаще, пусть и по рандому.
|
||||
assert stats["main_faction"]["faction_id"] == fids[0]
|
||||
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
|
||||
# Любимая больше не выводится из истории — она задаётся игроком вручную.
|
||||
assert stats["favorite_faction"] is None
|
||||
|
||||
|
||||
def test_main_faction_present_for_random_only_player(client: TestClient, 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, "Оппонент")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2, "was_random": True},
|
||||
],
|
||||
)
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
assert stats["main_faction"]["faction_id"] == fids[0]
|
||||
|
||||
|
||||
def test_favorite_faction_set_and_clear(client: TestClient):
|
||||
"""Любимая фракция — личный выбор игрока: сохраняется и снимается."""
|
||||
login(client, "Выбирающий")
|
||||
fid = client.get("/api/factions").json()[0]["id"]
|
||||
|
||||
r = client.patch(
|
||||
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["favorite_faction_id"] == fid
|
||||
assert client.get("/api/users/me").json()["favorite_faction_id"] == fid
|
||||
# Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики.
|
||||
assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid
|
||||
|
||||
r = client.patch(
|
||||
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["favorite_faction_id"] is None
|
||||
assert client.get("/api/users/me/stats").json()["favorite_faction"] is None
|
||||
|
||||
|
||||
def test_favorite_faction_rejects_unknown(client: TestClient):
|
||||
login(client, "Фантазёр")
|
||||
r = client.patch(
|
||||
"/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_bio_update_keeps_favorite_faction(client: TestClient):
|
||||
"""Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию."""
|
||||
login(client, "Аккуратный")
|
||||
fid = client.get("/api/factions").json()[0]["id"]
|
||||
client.patch(
|
||||
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
|
||||
)
|
||||
|
||||
r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["bio"] == "Только био"
|
||||
assert r.json()["favorite_faction_id"] == fid
|
||||
|
||||
|
||||
def test_prepositional_dictionary_and_fallback():
|
||||
"""Падежная форма берётся из словаря; незнакомый код — именительный как есть."""
|
||||
from app.services import faction_service
|
||||
|
||||
assert faction_service.prepositional("orks", "Орки") == "Орках"
|
||||
assert (
|
||||
faction_service.prepositional("astra_militarum", "Имперская гвардия")
|
||||
== "Имперской гвардии"
|
||||
)
|
||||
# Фракция, заведённая админом мимо словаря, не роняет вывод.
|
||||
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
|
||||
|
||||
|
||||
# ─── История партий в профиле (#1) ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _history(client: TestClient, user_id: int) -> dict:
|
||||
r = client.get(f"/api/users/{user_id}/matches")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]:
|
||||
"""Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id)."""
|
||||
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"]
|
||||
mates = [add_group_member(engine, gid, nick) for nick in nicknames]
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return gid, mates, fids
|
||||
|
||||
|
||||
def test_history_lists_only_own_finished_matches(client: TestClient, engine):
|
||||
"""В историю идут только завершённые партии этого игрока."""
|
||||
me = login(client, "Историк")
|
||||
gid, (mate,), fids = _group_with(client, engine, "Сосед")
|
||||
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||
{"user_id": mate, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
# Незавершённая партия мест не имеет и в историю попадать не должна.
|
||||
assert start_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[2]},
|
||||
{"user_id": mate, "faction_id": fids[3]},
|
||||
],
|
||||
).status_code == 200
|
||||
|
||||
data = _history(client, me["id"])
|
||||
assert data["total"] == 1
|
||||
assert [m["status"] for m in data["items"]] == ["finished"]
|
||||
# Значения по умолчанию едут вместе со списком — гостю хватает одного запроса.
|
||||
assert data["mode"] == "all"
|
||||
assert data["detail"] == "compact"
|
||||
|
||||
|
||||
def test_history_excludes_matches_without_the_player(client: TestClient, engine):
|
||||
"""Чужая партия в историю игрока не попадает, даже внутри его группы."""
|
||||
me = login(client, "Наблюдатель")
|
||||
gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3")
|
||||
|
||||
# Партию заводит сосед, сам игрок в ней не участвует.
|
||||
login(client, "Игрок2")
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": mate, "faction_id": fids[0], "place": 1},
|
||||
{"user_id": third, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
assert _history(client, me["id"])["total"] == 0
|
||||
assert _history(client, mate)["total"] == 1
|
||||
|
||||
|
||||
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
|
||||
"""Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую.
|
||||
|
||||
Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место
|
||||
в дуэли — отнимает."""
|
||||
me = login(client, "Лучший")
|
||||
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
|
||||
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": a, "faction_id": fids[0], "place": 1},
|
||||
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
|
||||
{"user_id": b, "faction_id": fids[2], "place": 3},
|
||||
{"user_id": c, "faction_id": fids[3], "place": 4},
|
||||
],
|
||||
)
|
||||
# Свежее, но по очкам хуже — последнее место на двоих.
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": a, "faction_id": fids[0], "place": 1},
|
||||
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
client.patch(
|
||||
"/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)
|
||||
)
|
||||
data = _history(client, me["id"])
|
||||
assert data["mode"] == "best"
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками
|
||||
|
||||
|
||||
def test_history_prefs_saved_and_validated(client: TestClient):
|
||||
"""Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое."""
|
||||
login(client, "Настройщик")
|
||||
r = client.patch(
|
||||
"/api/users/me/profile",
|
||||
json={"history_mode": "best", "history_detail": "full"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["history_mode"] == "best"
|
||||
assert r.json()["history_detail"] == "full"
|
||||
assert client.get("/api/users/me").json()["history_detail"] == "full"
|
||||
|
||||
bad = client.patch(
|
||||
"/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
assert client.get("/api/users/me").json()["history_mode"] == "best"
|
||||
|
||||
|
||||
def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
|
||||
"""Гость видит историю в том режиме, который выбрал владелец профиля."""
|
||||
me = login(client, "Витрина")
|
||||
gid, (mate,), fids = _group_with(client, engine, "Партнёр")
|
||||
for _ in range(2):
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||
{"user_id": mate, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
client.patch(
|
||||
"/api/users/me/profile",
|
||||
json={"history_mode": "best", "history_detail": "full"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
|
||||
login(client, "Прохожий")
|
||||
data = _history(client, me["id"])
|
||||
assert data["mode"] == "best"
|
||||
assert data["detail"] == "full"
|
||||
assert data["total"] == 1
|
||||
|
||||
|
||||
def test_avatar_version_is_stable_across_surfaces(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
"""Кэш-бастер аватара одинаков в профиле и в лидерборде, и меняется при перезаливке.
|
||||
|
||||
Регрессия: версию профиля считал Python из наивного времени как из локального,
|
||||
а лидерборд — SQL как из UTC, и браузер тянул одну картинку дважды. Плюс при
|
||||
том же расширении файла updated_at не двигался и ссылка оставалась прежней."""
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
me = login(client, "Версия")
|
||||
_finished_match_for(client, engine, me)
|
||||
|
||||
def version_in(url: str) -> str:
|
||||
return url.split("?v=")[1]
|
||||
|
||||
def leaderboard_url() -> str:
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
entry = next(
|
||||
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
|
||||
)
|
||||
return entry["avatar_url"]
|
||||
|
||||
first = client.put(
|
||||
"/api/users/me/avatar",
|
||||
files={"file": ("a.png", PNG, "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
v_profile = version_in(first.json()["avatar_url"])
|
||||
assert version_in(leaderboard_url()) == v_profile
|
||||
|
||||
# Повторная загрузка с тем же расширением: avatar_path не меняется, поэтому UPDATE
|
||||
# строки сам собой не эмитится — updated_at должен двигаться явно, иначе кэш-бастер
|
||||
# замирает и браузер час показывает прежнюю картинку. Версия в ссылке считается с
|
||||
# точностью до секунды, поэтому сдвиг проверяем по времени в БД.
|
||||
with Session(engine) as s:
|
||||
before = s.get(User, me["id"]).updated_at
|
||||
second = client.put(
|
||||
"/api/users/me/avatar",
|
||||
files={"file": ("a.png", PNG + b"\x00", "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
with Session(engine) as s:
|
||||
assert s.get(User, me["id"]).updated_at > before
|
||||
assert version_in(leaderboard_url()) == version_in(second.json()["avatar_url"])
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Движок рейтинга: примеры docs/rating/rating-system.md и сверка с эталоном simulate.py.
|
||||
|
||||
Числа примеров — те же, что в документе (раздел 6) и в EXPECTED эталона: разъехаться
|
||||
документ, эталон и приложение не должны. Сверка с simulate.py дополнительно гоняет
|
||||
синтетический сезон и требует совпадения каждого изменения рейтинга."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import scoring
|
||||
from app.services.scoring import RatedMatch, RatedSeat, rate_match, replay
|
||||
|
||||
VETERAN = 40 # партий у «опытного» игрока: K = K_MIN
|
||||
A, B, C, D, E, F = 1, 2, 3, 4, 5, 6
|
||||
|
||||
|
||||
def _vets(*ids: int) -> dict[int, int]:
|
||||
return dict.fromkeys(ids, VETERAN)
|
||||
|
||||
|
||||
def _duel(first: int, second: int, **kw) -> RatedMatch:
|
||||
return RatedMatch((RatedSeat(first, 1), RatedSeat(second, 2)), **kw)
|
||||
|
||||
|
||||
def _seat(uid: int, place: int, objectives=None, worlds=None, eliminated=False) -> RatedSeat:
|
||||
return RatedSeat(uid, place, objectives=objectives, worlds=worlds, eliminated=eliminated)
|
||||
|
||||
|
||||
FIVE = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E)))
|
||||
SIX = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E, F)))
|
||||
|
||||
# (ключ, рейтинги, сыграно партий, партия, ожидаемые ΔR с точностью до 0.01)
|
||||
EXAMPLES = [
|
||||
("1a", {A: 1600, B: 1400}, _vets(A, B), _duel(A, B, win_reason="objectives"),
|
||||
{A: 3.84, B: -3.84}),
|
||||
("1b", {A: 1600, B: 1400}, _vets(A, B), _duel(B, A, win_reason="objectives"),
|
||||
{B: 12.16, A: -12.16}),
|
||||
("2a", {A: 1500, B: 1500}, _vets(A, B),
|
||||
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=3),
|
||||
{A: 11.18, B: -11.18}),
|
||||
("2b", {A: 1500, B: 1500}, _vets(A, B),
|
||||
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=8),
|
||||
{A: 5.46, B: -5.46}),
|
||||
("3a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="objectives"),
|
||||
{A: 8.0, B: -8.0}),
|
||||
("3b", dict.fromkeys((A, B, C, D, E), 1500), _vets(A, B, C, D, E),
|
||||
RatedMatch(FIVE, "objectives"),
|
||||
{A: 11.0, B: 5.5, C: 0.0, D: -5.5, E: -11.0}),
|
||||
("4a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="worlds"),
|
||||
{A: 6.8, B: -6.8}),
|
||||
("4b", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="plastic"),
|
||||
{A: 5.6, B: -5.6}),
|
||||
("4c", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="resources"),
|
||||
{A: 4.8, B: -4.8}),
|
||||
("4d", {A: 1500, B: 1500}, _vets(A, B),
|
||||
RatedMatch((_seat(A, 1, 2, 6), _seat(B, 2, 2, 5)), "worlds", end_round=8),
|
||||
{A: 3.4, B: -3.4}),
|
||||
("4e", {A: 1500, B: 1500}, _vets(A, B),
|
||||
RatedMatch((_seat(A, 1, 2, 8), _seat(B, 2, 0, 2)), "objectives", end_round=3),
|
||||
{A: 16.0, B: -16.0}),
|
||||
("5", {A: 1550, B: 1500, C: 1480, D: 1450}, _vets(A, B, C, D),
|
||||
RatedMatch(
|
||||
(_seat(A, 1, 4, 8), _seat(B, 2, 3, 7),
|
||||
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
||||
"objectives", end_round=7,
|
||||
),
|
||||
{A: 9.27, B: 5.85, C: -7.6, D: -7.53}),
|
||||
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
||||
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
|
||||
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
|
||||
("6b", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
||||
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=False),
|
||||
{A: 10.29, B: 7.54, C: 2.74, D: -2.06, E: -6.86, F: -11.66}),
|
||||
("7", {A: 1500, B: 1500}, {A: 0, B: VETERAN}, _duel(A, B, win_reason="objectives"),
|
||||
{A: 32.0, B: -8.0}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ratings,games,match,want", [e[1:] for e in EXAMPLES], ids=[e[0] for e in EXAMPLES]
|
||||
)
|
||||
def test_document_examples(ratings, games, match, want):
|
||||
delta, _perf = rate_match(ratings, games, match)
|
||||
assert {uid: round(v, 2) for uid, v in delta.items()} == want
|
||||
|
||||
|
||||
def test_examples_cover_whole_section():
|
||||
assert len(EXAMPLES) == 15
|
||||
|
||||
|
||||
def test_last_standing_counts_full_objective_gap():
|
||||
"""Победа last_standing: отрыв победителя по целям = 1, сколько бы маркеров ни было."""
|
||||
seats = (_seat(A, 1, 1, 6), _seat(B, 2, 1, 0, eliminated=True))
|
||||
ordinary = rate_match({}, _vets(A, B), RatedMatch(seats, "objectives"))[0][A]
|
||||
standing = rate_match({}, _vets(A, B), RatedMatch(seats, "last_standing"))[0][A]
|
||||
# Отрыв по целям 1 вместо 0 → множитель больше на W_OBJ·1 = 0.5; ΔR = K·ΔM·(S − E).
|
||||
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
|
||||
|
||||
|
||||
def test_eliminated_are_not_compared_with_each_other():
|
||||
"""Выбывшие между собой не сравниваются (#91): слабый выбывший среди сильных не
|
||||
получает рейтинг, а рейтинги прочих выбывших на его изменение не влияют."""
|
||||
six = range(A, F + 1)
|
||||
seats = (_seat(A, 1),) + tuple(_seat(u, 2, eliminated=True) for u in six if u != A)
|
||||
match = RatedMatch(seats, "last_standing")
|
||||
strong = {**dict.fromkeys(six, 1800), F: 1200}
|
||||
delta = rate_match(strong, _vets(*six), match)[0]
|
||||
assert delta[F] < 0
|
||||
# Сильные выбывшие → слабые: у F и у победителя ничего не меняется от этого.
|
||||
weak = {**dict.fromkeys(six, 1200), A: 1800}
|
||||
again = rate_match(weak, _vets(*six), match)[0]
|
||||
assert again[F] == pytest.approx(delta[F])
|
||||
# Победителю сила соперников по-прежнему важна: против слабых он получает меньше.
|
||||
assert again[A] < delta[A]
|
||||
|
||||
|
||||
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
|
||||
|
||||
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sim():
|
||||
if not SIMULATE.exists():
|
||||
pytest.skip("docs/rating/simulate.py недоступен")
|
||||
spec = importlib.util.spec_from_file_location("rating_simulate", SIMULATE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module # dataclasses ищут модуль по имени
|
||||
spec.loader.exec_module(module) # type: ignore[union-attr]
|
||||
return module
|
||||
|
||||
|
||||
def _convert(m, ids: dict[str, int], match_id: int) -> RatedMatch:
|
||||
return RatedMatch(
|
||||
tuple(
|
||||
RatedSeat(
|
||||
ids[s.player], s.place, eliminated=s.eliminated,
|
||||
objectives=s.objectives, worlds=s.worlds,
|
||||
)
|
||||
for s in m.seats
|
||||
),
|
||||
m.win_reason,
|
||||
end_round=m.round,
|
||||
nine_rounds_rule=m.nine_rounds,
|
||||
id=match_id,
|
||||
)
|
||||
|
||||
|
||||
def test_constants_match_reference(sim):
|
||||
p = sim.PROPOSED
|
||||
assert (p.r0, p.d, p.k_max, p.k_min, p.k_games) == (
|
||||
scoring.R0, scoring.D, scoring.K_MAX, scoring.K_MIN, scoring.K_GAMES
|
||||
)
|
||||
assert (p.w_table, p.w_tempo, p.w_obj, p.w_worlds) == (
|
||||
scoring.W_TABLE, scoring.W_TEMPO, scoring.W_OBJ, scoring.W_WORLDS
|
||||
)
|
||||
assert (p.mu_obj, p.mu_worlds, p.m_min, p.m_max) == (
|
||||
scoring.MU_OBJ, scoring.MU_WORLDS, scoring.M_MIN, scoring.M_MAX
|
||||
)
|
||||
assert dict(p.closeness) == scoring.CLOSENESS
|
||||
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
||||
assert not p.autocorr
|
||||
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
||||
@pytest.mark.parametrize("stripped", [False, True], ids=["full", "history"])
|
||||
def test_replay_matches_reference_season(sim, scenario, stripped):
|
||||
"""Весь сезон: каждое изменение рейтинга совпадает с эталоном до 1e-9."""
|
||||
cfg = sim.SCENARIOS[scenario]
|
||||
_skill, matches = sim.generate_season(
|
||||
cfg["seed"], sim.SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
|
||||
)
|
||||
if stripped:
|
||||
matches = [sim.strip_details(m) for m in matches]
|
||||
ids: dict[str, int] = {}
|
||||
for m in matches:
|
||||
for s in m.seats:
|
||||
ids.setdefault(s.player, len(ids) + 1)
|
||||
|
||||
reference = sim.Elo(sim.PROPOSED)
|
||||
ours = replay(_convert(m, ids, i) for i, m in enumerate(matches))
|
||||
for i, m in enumerate(matches):
|
||||
for player, dv in reference.update(m).items():
|
||||
assert ours.delta[(i, ids[player])] == pytest.approx(dv, abs=1e-9)
|
||||
for player, uid in ids.items():
|
||||
assert ours.ratings[uid] == pytest.approx(reference.rating(player), abs=1e-6)
|
||||
|
||||
|
||||
def test_monotone_and_zero_sum(sim):
|
||||
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
|
||||
при равных K сумма изменений за партию — ноль."""
|
||||
cfg = sim.SCENARIOS["сигнал"]
|
||||
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
||||
ids: dict[str, int] = {}
|
||||
for m in matches:
|
||||
for s in m.seats:
|
||||
ids.setdefault(s.player, len(ids) + 1)
|
||||
veterans = dict.fromkeys(ids.values(), VETERAN)
|
||||
for i, m in enumerate(matches):
|
||||
rm = _convert(m, ids, i)
|
||||
delta, _ = rate_match({}, veterans, rm)
|
||||
places = [s.place for s in rm.seats]
|
||||
for s in rm.seats:
|
||||
if s.eliminated:
|
||||
assert delta[s.user_id] < 0
|
||||
if places.count(s.place) > 1:
|
||||
continue
|
||||
if s.place == 1:
|
||||
assert delta[s.user_id] > 0
|
||||
if s.place == max(places):
|
||||
assert delta[s.user_id] < 0
|
||||
assert sum(delta.values()) == pytest.approx(0.0, abs=1e-9)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов,
|
||||
причина «последний выживший». Сервер проверяет диапазоны и явные противоречия."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]:
|
||||
"""Группа со всеми дополнениями и players участниками (первый — вошедший)."""
|
||||
me = login(client, "Хозяин")
|
||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)]
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return gid, uids, fids
|
||||
|
||||
|
||||
def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
|
||||
r = start_match(
|
||||
client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)]
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra):
|
||||
body = {"participants": participants, "win_reason": win_reason, **extra}
|
||||
return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client))
|
||||
|
||||
|
||||
def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict:
|
||||
r = client.patch(
|
||||
f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
assert match["max_rounds"] == 8 and match["end_round"] is None
|
||||
|
||||
r = _finish(
|
||||
client, match["id"],
|
||||
[
|
||||
{"user_id": a, "place": 1, "objectives": 2, "worlds": 5},
|
||||
{"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся
|
||||
],
|
||||
"objectives", end_round=6,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["end_round"] == 6
|
||||
parts = {p["user_id"]: p for p in data["participants"]}
|
||||
assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5)
|
||||
assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None)
|
||||
|
||||
listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"]
|
||||
assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1}
|
||||
|
||||
|
||||
def test_end_round_limited_by_max_rounds(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}]
|
||||
assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422
|
||||
assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422
|
||||
assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200
|
||||
|
||||
|
||||
def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine):
|
||||
gid, uids, fids = _group(client, engine, 5)
|
||||
assert _set_rule(client, gid, True)["nine_rounds_rule"] is True
|
||||
assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто
|
||||
|
||||
five = _start(client, gid, uids, fids)
|
||||
four = _start(client, gid, uids[:4], fids)
|
||||
assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9)
|
||||
assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5
|
||||
|
||||
# Смена настройки группы не переписывает уже начатую партию.
|
||||
_set_rule(client, gid, False)
|
||||
assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9
|
||||
|
||||
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
|
||||
assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200
|
||||
rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])]
|
||||
assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422
|
||||
|
||||
|
||||
def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine):
|
||||
gid, (a, b, c), fids = _group(client, engine, 3)
|
||||
match = _start(client, gid, [a, b, c], fids)
|
||||
alone = [
|
||||
{"user_id": a, "place": 1},
|
||||
{"user_id": b, "eliminated": True},
|
||||
{"user_id": c, "eliminated": True},
|
||||
]
|
||||
r = _finish(client, match["id"], alone, "objectives")
|
||||
assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"]
|
||||
|
||||
two = [
|
||||
{"user_id": a, "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
{"user_id": c, "eliminated": True},
|
||||
]
|
||||
assert _finish(client, match["id"], two, "last_standing").status_code == 422
|
||||
|
||||
ok = _finish(client, match["id"], alone, "last_standing")
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["win_reason"] == "last_standing"
|
||||
|
||||
|
||||
def test_eliminated_player_has_no_worlds(client: TestClient, engine):
|
||||
gid, (a, b, c), fids = _group(client, engine, 3)
|
||||
match = _start(client, gid, [a, b, c], fids)
|
||||
rows = [
|
||||
{"user_id": a, "place": 1, "worlds": 7},
|
||||
{"user_id": b, "place": 2, "worlds": 4},
|
||||
{"user_id": c, "eliminated": True, "worlds": 2},
|
||||
]
|
||||
assert _finish(client, match["id"], rows, "objectives").status_code == 422
|
||||
|
||||
rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны
|
||||
r = _finish(client, match["id"], rows, "objectives")
|
||||
assert r.status_code == 200, r.text
|
||||
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
||||
assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1)
|
||||
|
||||
|
||||
def test_negative_counts_rejected(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}]
|
||||
assert _finish(client, match["id"], rows, "objectives").status_code == 422
|
||||
|
||||
|
||||
def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
|
||||
match = _start(client, gid, uids, fids)
|
||||
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
|
||||
r = _finish(client, match["id"], rows, "objectives")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _edit_rows(match: dict, **by_user) -> list[dict]:
|
||||
rows = []
|
||||
for p in match["participants"]:
|
||||
row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]}
|
||||
row.update(by_user.get(str(p["user_id"]), {}))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def test_edit_checks_last_standing_and_round(client: TestClient, engine):
|
||||
gid, (a, b, c), fids = _group(client, engine, 3)
|
||||
match = _finished(client, gid, [a, b, c], fids)
|
||||
mid = match["id"]
|
||||
|
||||
def patch(body: dict):
|
||||
return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
|
||||
|
||||
elim = {"place": None, "eliminated": True}
|
||||
rows = _edit_rows(match, **{str(b): elim, str(c): elim})
|
||||
assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422
|
||||
# Причина не передана — сверяется с записанной («по целям»): тоже противоречие.
|
||||
assert patch({"participants": rows}).status_code == 422
|
||||
r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5})
|
||||
assert r.status_code == 200, r.text
|
||||
assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5)
|
||||
|
||||
# Одна только причина: при одном выжившем вернуть «по целям» нельзя.
|
||||
assert patch({"win_reason": "objectives"}).status_code == 422
|
||||
# Один только раунд: в пределах лимита — можно, за лимитом — нет.
|
||||
assert patch({"end_round": 9}).status_code == 422
|
||||
assert patch({"end_round": None}).json()["end_round"] is None
|
||||
|
||||
|
||||
def test_admin_edit_saves_counts(client: TestClient, engine, make_admin):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _finished(client, gid, [a, b], fids)
|
||||
make_admin("admin", "secret123")
|
||||
assert client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
).status_code == 200
|
||||
rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}})
|
||||
r = client.patch(
|
||||
f"/api/admin/matches/{match['id']}",
|
||||
json={"participants": rows, "win_reason": "objectives", "end_round": 7},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
||||
assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7)
|
||||
|
||||
|
||||
def test_draft_keeps_round_and_counts(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
body = {
|
||||
"blocks": [[a], [b]],
|
||||
"win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется
|
||||
"end_round": 4,
|
||||
"objectives": {str(a): 2},
|
||||
"worlds": {str(a): 5, str(b): 3},
|
||||
}
|
||||
r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client))
|
||||
assert r.status_code == 200, r.text
|
||||
data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"]
|
||||
assert data["end_round"] == 4
|
||||
assert data["objectives"] == {str(a): 2}
|
||||
assert data["worlds"] == {str(a): 5, str(b): 3}
|
||||
|
||||
bad = client.put(
|
||||
f"/api/matches/{match['id']}/finish-draft",
|
||||
json={"worlds": {str(a): -3}},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Рейтинг в витринах (#23, #80): Elo по упорядоченной истории, один рейтинг на игрока."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services.scoring import RatedMatch, RatedSeat, replay
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
|
||||
def _group(client: TestClient, name: str = "Группа") -> tuple[int, list[int]]:
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return gid, fids
|
||||
|
||||
|
||||
def _duel(client: TestClient, gid: int, fids: list[int], winner: int, loser: int) -> dict:
|
||||
return create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": winner, "faction_id": fids[0], "place": 1},
|
||||
{"user_id": loser, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _board(client: TestClient, path: str = "/api/stats/leaderboard") -> dict[int, dict]:
|
||||
data = client.get(path).json()
|
||||
rows = data["entries"] + data["provisional"] if "entries" in data else (
|
||||
data["leaderboard"] + data["provisional"]
|
||||
)
|
||||
return {e["user_id"]: e for e in rows}
|
||||
|
||||
|
||||
def test_newcomer_duel_moves_rating_by_32(client: TestClient, engine):
|
||||
"""Первая дуэль новичков на 1500: K = 64, ожидание 0.5, деталей нет → ±32."""
|
||||
me = login(client, "Хозяин")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Гость")
|
||||
_duel(client, gid, fids, me["id"], b)
|
||||
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
|
||||
assert by_id[me["id"]]["score"] == 1532
|
||||
assert by_id[b]["score"] == 1468
|
||||
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
|
||||
assert board["entries"] == []
|
||||
assert board["min_games"] == 10
|
||||
|
||||
prof = client.get("/api/users/me/stats").json()
|
||||
assert prof["overall"]["score"] == 1532
|
||||
# Фракционная метрика — S − E: победа при шансах 0.5 даёт +0.5 → 50.0.
|
||||
assert prof["factions"][0]["score"] == 50.0
|
||||
|
||||
|
||||
def test_ranked_after_min_games(client: TestClient, engine):
|
||||
me = login(client, "Чемпион")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Спарринг")
|
||||
for _ in range(10):
|
||||
_duel(client, gid, fids, me["id"], b)
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
ranks = {e["user_id"]: (e["rank"], e["score"]) for e in board["entries"]}
|
||||
assert ranks[me["id"]][0] == 1 and ranks[b][0] == 2
|
||||
assert ranks[me["id"]][1] > 1500 > ranks[b][1]
|
||||
assert isinstance(ranks[me["id"]][1], int)
|
||||
|
||||
|
||||
def test_editing_past_match_recalculates_later_ones(client: TestClient, engine):
|
||||
"""Рейтинг — функция истории: правка первой партии меняет итог после второй."""
|
||||
me = login(client, "А")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Б")
|
||||
first = _duel(client, gid, fids, me["id"], b)
|
||||
_duel(client, gid, fids, me["id"], b)
|
||||
|
||||
def expected(first_winner: int, first_loser: int) -> dict[int, int]:
|
||||
rep = replay([
|
||||
RatedMatch((RatedSeat(first_winner, 1), RatedSeat(first_loser, 2)), "objectives", id=1),
|
||||
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=2),
|
||||
])
|
||||
return {uid: round(r) for uid, r in rep.ratings.items()}
|
||||
|
||||
before = expected(me["id"], b)
|
||||
assert {uid: e["score"] for uid, e in _board(client).items()} == before
|
||||
|
||||
rows = [
|
||||
{"user_id": b, "faction_id": fids[1], "place": 1},
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 2},
|
||||
]
|
||||
r = client.patch(
|
||||
f"/api/matches/{first['id']}",
|
||||
json={"participants": rows, "win_reason": "objectives"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
after = expected(b, me["id"])
|
||||
assert after != before
|
||||
assert {uid: e["score"] for uid, e in _board(client).items()} == after
|
||||
|
||||
|
||||
def test_group_page_shows_overall_rating_with_group_stats(client: TestClient, engine):
|
||||
"""Рейтинг один на всё приложение (#80): в группе он тот же, что в общем топе,
|
||||
а игры и победы — только по партиям группы."""
|
||||
me = login(client, "Путешественник")
|
||||
g1, f1 = _group(client, "Первая")
|
||||
g2, f2 = _group(client, "Вторая")
|
||||
b = add_group_member(engine, g1, "Сосед")
|
||||
c = add_group_member(engine, g2, "Соседка")
|
||||
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
|
||||
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
|
||||
|
||||
overall = _board(client)[me["id"]]
|
||||
assert overall["games"] == 2
|
||||
assert overall["score"] == round(
|
||||
replay([
|
||||
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
|
||||
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
|
||||
]).ratings[me["id"]]
|
||||
)
|
||||
first = _board(client, f"/api/groups/{g1}/stats")[me["id"]]
|
||||
second = _board(client, f"/api/groups/{g2}/stats")[me["id"]]
|
||||
assert first["score"] == second["score"] == overall["score"]
|
||||
assert (first["games"], first["wins"]) == (1, 1)
|
||||
assert (second["games"], second["wins"]) == (1, 0)
|
||||
|
||||
# Главная и профиль — общие показатели; блок активной группы — игры в группе.
|
||||
client.put("/api/users/me/active-group", json={"group_id": g2}, headers=csrf_headers(client))
|
||||
home = client.get("/api/home").json()
|
||||
assert (home["profile"]["overall"]["games"], home["profile"]["overall"]["score"]) == (
|
||||
2, overall["score"]
|
||||
)
|
||||
assert (home["active_group"]["games"], home["active_group"]["score"]) == (1, overall["score"])
|
||||
|
||||
|
||||
def test_veteran_is_not_a_newcomer_in_new_group(client: TestClient, engine):
|
||||
"""Статус «Новичок» — про надёжность рейтинга, а он общий: 10 партий где угодно
|
||||
делают игрока ранжированным и в группе, где он сыграл одну."""
|
||||
me = login(client, "Ветеран")
|
||||
g1, f1 = _group(client, "Старая")
|
||||
b = add_group_member(engine, g1, "Спарринг")
|
||||
for _ in range(10):
|
||||
_duel(client, g1, f1, me["id"], b)
|
||||
|
||||
g2, f2 = _group(client, "Новая")
|
||||
c = add_group_member(engine, g2, "Новенький")
|
||||
add_group_member(engine, g2, "Спарринг") # опытный, но в новой группе не играл
|
||||
d = add_group_member(engine, g2, "Зритель") # не играл нигде
|
||||
_duel(client, g2, f2, me["id"], c)
|
||||
|
||||
stats = client.get(f"/api/groups/{g2}/stats").json()
|
||||
ranked = {e["user_id"]: e for e in stats["leaderboard"]}
|
||||
assert ranked[me["id"]]["rank"] == 1
|
||||
assert (ranked[me["id"]]["games"], ranked[me["id"]]["rating_confirmed"]) == (1, True)
|
||||
assert ranked[me["id"]]["score"] == _board(client)[me["id"]]["score"]
|
||||
assert [e["user_id"] for e in stats["provisional"]] == [c]
|
||||
|
||||
inactive = {e["user_id"]: e for e in stats["inactive"]}
|
||||
assert inactive[b]["score"] == _board(client)[b]["score"]
|
||||
assert (inactive[b]["games"], inactive[b]["rating_confirmed"]) == (0, True)
|
||||
assert (inactive[d]["score"], inactive[d]["rating_confirmed"]) == (None, False)
|
||||
|
||||
|
||||
def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
|
||||
"""Лучшая партия — наибольший прирост рейтинга, а не свежая из равных побед.
|
||||
|
||||
Обе партии — победы в дуэли. Первая — новичком над равным (+32), вторая — уже
|
||||
с рейтингом 1532 и меньшим K над новичком (≈ +28): лучше первая."""
|
||||
me = login(client, "Лучший")
|
||||
gid, fids = _group(client)
|
||||
x = add_group_member(engine, gid, "Икс")
|
||||
y = add_group_member(engine, gid, "Игрек")
|
||||
first = _duel(client, gid, fids, me["id"], x)
|
||||
_duel(client, gid, fids, me["id"], y)
|
||||
|
||||
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
|
||||
data = client.get(f"/api/users/{me['id']}/matches").json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["id"] == first["id"]
|
||||
|
||||
|
||||
def test_match_left_with_one_participant_is_not_a_game(client: TestClient, engine, make_admin):
|
||||
"""Dev-удаление аккаунта вычёркивает игрока из партий, не трогая сами партии. Партия,
|
||||
где остался один участник, — не игра: её нет в рейтинге, историях и списках группы
|
||||
(карточка «с одним игроком» раньше висела в профиле)."""
|
||||
me = login(client, "Выживший")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Удалённый")
|
||||
c = add_group_member(engine, gid, "Соперник")
|
||||
orphan = _duel(client, gid, fids, me["id"], b)
|
||||
kept = _duel(client, gid, fids, c, me["id"])
|
||||
|
||||
make_admin("admin", "secret123")
|
||||
assert client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
).status_code == 200
|
||||
assert client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)).status_code == 200
|
||||
# Админка партию по-прежнему видит — удалить её можно оттуда.
|
||||
assert client.get(f"/api/admin/matches/{orphan['id']}").status_code == 200
|
||||
|
||||
history = client.get(f"/api/users/{me['id']}/matches").json()
|
||||
assert (history["total"], [i["id"] for i in history["items"]]) == (1, [kept["id"]])
|
||||
group_list = client.get(f"/api/groups/{gid}/matches").json()
|
||||
assert (group_list["total"], [i["id"] for i in group_list["items"]]) == (1, [kept["id"]])
|
||||
assert client.get(f"/api/groups/{gid}/stats").json()["total_matches"] == 1
|
||||
|
||||
# В рейтинге — только настоящая партия: дуэль новичков, проигрыш −32.
|
||||
board = _board(client)
|
||||
assert (board[me["id"]]["games"], board[me["id"]]["score"]) == (1, 1468)
|
||||
assert board[c]["score"] == 1532
|
||||
|
||||
|
||||
def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine):
|
||||
"""История игрока несёт изменение его общего рейтинга за каждую партию (#77)."""
|
||||
me = login(client, "Историк")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Оппонент")
|
||||
_duel(client, gid, fids, me["id"], b) # новички на 1500: ±32
|
||||
_duel(client, gid, fids, b, me["id"]) # реванш: 1468 обыгрывает 1532
|
||||
|
||||
mine = client.get(f"/api/users/{me['id']}/matches").json()["items"]
|
||||
theirs = client.get(f"/api/users/{b}/matches").json()["items"]
|
||||
# Свежие сверху: реванш первым.
|
||||
assert (mine[1]["rating_delta"], theirs[1]["rating_delta"]) == (32.0, -32.0)
|
||||
assert mine[0]["rating_delta"] == -theirs[0]["rating_delta"] < 0
|
||||
# Изменения складываются в рейтинг (с точностью округления до десятых).
|
||||
score = _board(client)[me["id"]]["score"]
|
||||
assert abs(1500 + sum(i["rating_delta"] for i in mine) - score) <= 0.6
|
||||
|
||||
# В списке партий группы дельты нет — непонятно, чья она была бы.
|
||||
group_items = client.get(f"/api/groups/{gid}/matches").json()["items"]
|
||||
assert [i["rating_delta"] for i in group_items] == [None, None]
|
||||
|
||||
# Режим «лучшая партия» тоже её отдаёт.
|
||||
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
|
||||
best = client.get(f"/api/users/{me['id']}/matches").json()["items"]
|
||||
assert best[0]["rating_delta"] == 32.0
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services import stats_service
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
|
||||
def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dict, int, int]:
|
||||
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, "Соперник")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
for i in range(games):
|
||||
# Чередуем победителя и берём разные фракции: средние и разбивка по фракциям
|
||||
# должны получиться нетривиальными, а фракции в партии не повторяться.
|
||||
winner_first = i % 2 == 0
|
||||
mine, theirs = fids[(2 * i) % len(fids)], fids[(2 * i + 1) % len(fids)]
|
||||
create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": mine, "place": 1 if winner_first else 2},
|
||||
{"user_id": p2, "faction_id": theirs, "place": 2 if winner_first else 1},
|
||||
],
|
||||
)
|
||||
return me, gid, p2
|
||||
|
||||
|
||||
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
|
||||
"""Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
|
||||
me, gid, p2 = _group_with_matches(client, engine, games=4)
|
||||
|
||||
profile = client.get("/api/users/me/stats").json()["overall"]
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
entry = next(
|
||||
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
|
||||
)
|
||||
|
||||
for field in ("games", "wins", "win_rate", "avg_place", "score"):
|
||||
assert profile[field] == entry[field], field
|
||||
|
||||
|
||||
def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
|
||||
"""Главная грузит историю партий один раз: топ, профиль и итог активной группы
|
||||
считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин."""
|
||||
me, gid, p2 = _group_with_matches(client, engine, games=2)
|
||||
client.put(
|
||||
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
||||
)
|
||||
|
||||
calls: list[int] = []
|
||||
original = stats_service.load_history
|
||||
|
||||
def spy(session):
|
||||
calls.append(1)
|
||||
return original(session)
|
||||
|
||||
monkeypatch.setattr(stats_service, "load_history", spy)
|
||||
r = client.get("/api/home")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(calls) == 1
|
||||
# Главная всё ещё показывает и профиль, и блок активной группы.
|
||||
body = r.json()
|
||||
assert body["profile"]["overall"]["games"] == 2
|
||||
assert body["active_group"]["games"] == 2
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Поиск игроков для автокомплита по нику: GET /api/users/search."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import User
|
||||
from tests.conftest import login
|
||||
|
||||
|
||||
def _add_user(engine, nickname: str, *, active: bool = True, role: str = "player") -> None:
|
||||
"""Готовим данные напрямую в БД: нужны выключенные аккаунты и админы."""
|
||||
with Session(engine) as s:
|
||||
s.add(
|
||||
User(
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
auth_provider="stub" if role == "player" else "local",
|
||||
is_active=active,
|
||||
# CHECK ck_users_admin_has_password: админ обязан иметь пароль.
|
||||
password_hash="x" if role == "admin" else None,
|
||||
)
|
||||
)
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_search_prefix_first_cyrillic_case_insensitive(client: TestClient, engine):
|
||||
login(client, "Искатель")
|
||||
for nick in ("Барсик", "барс", "Морбарс", "Zed"):
|
||||
_add_user(engine, nick)
|
||||
|
||||
r = client.get("/api/users/search", params={"q": "БАР"})
|
||||
assert r.status_code == 200, r.text
|
||||
got = r.json()
|
||||
# Префиксные раньше подстрочных, внутри — по алфавиту; регистр кириллицы не важен.
|
||||
assert [u["nickname"] for u in got] == ["барс", "Барсик", "Морбарс"]
|
||||
assert {"user_id", "nickname", "avatar_url"} <= set(got[0].keys())
|
||||
|
||||
|
||||
def test_search_empty_query_returns_empty_list(client: TestClient):
|
||||
login(client, "Искатель")
|
||||
r = client.get("/api/users/search", params={"q": " "})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_search_skips_inactive_and_admins(client: TestClient, engine):
|
||||
login(client, "Искатель")
|
||||
_add_user(engine, "Спящий барсук", active=False)
|
||||
_add_user(engine, "адм барс", role="admin")
|
||||
|
||||
r = client.get("/api/users/search", params={"q": "барс"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_search_respects_limit(client: TestClient, engine):
|
||||
login(client, "Искатель")
|
||||
for i in range(5):
|
||||
_add_user(engine, f"Игрок {i}")
|
||||
|
||||
assert len(client.get("/api/users/search", params={"q": "игрок"}).json()) == 3
|
||||
assert (
|
||||
len(client.get("/api/users/search", params={"q": "игрок", "limit": 5}).json()) == 5
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Ошибки валидации запроса: всегда 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
|
||||
+31
-23
@@ -1,6 +1,6 @@
|
||||
# Публикация: домены, VPS, туннели
|
||||
|
||||
Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет
|
||||
Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет
|
||||
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
|
||||
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
|
||||
приложение само открывает к VPS.
|
||||
@@ -11,37 +11,40 @@ HTTPS твоими сертификатами и проксирует трафи
|
||||
│ ▲ туннель-КОНТЕЙНЕР │
|
||||
│ └── Pi : app:8000 PROD │
|
||||
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
|
||||
│ ▲ контейнер (test) ИЛИ │
|
||||
│ ▲ ssh с ПК (dev) │
|
||||
│ ├── ПК test : app:8000 │
|
||||
│ └── ПК dev : vite:5173 │
|
||||
│ ▲ ssh с ПК (по требованию) │
|
||||
│ └── ПК dev : vite:5173 DEV │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **PROD** — Pi. `docker compose up -d` поднимает два сервиса: `app` + `tunnel`. У `app`
|
||||
**портов на хост нет** — наружу его выставляет только туннель-контейнер
|
||||
(`ssh -R 9000:app:8000` к VPS). Постоянно, Docker сам переподключает. См. [`pi/`](pi/README.md).
|
||||
- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` поднимает
|
||||
`app` + `tunnel` (`ssh -R 9001:app:8000`). Портов на хост нет — тест виден только на
|
||||
`forbidden-stars.ru`. Обычно запускается лаунчером при `APP_ENV=test`.
|
||||
- **PROD** — Pi. `docker compose up -d` поднимает три сервиса: `app` + `tunnel` + `backup`
|
||||
(образы из Gitea-реестра, собираются на ПК `scripts/build-push.ps1`). У `app` **портов на
|
||||
хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS).
|
||||
Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер
|
||||
(`restart: unless-stopped`). См. [`pi/`](pi/README.md).
|
||||
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
|
||||
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1`) дополнительно поднимает SSH-туннель с ПК
|
||||
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`.
|
||||
- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**.
|
||||
PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо.
|
||||
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК
|
||||
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**).
|
||||
- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev.
|
||||
Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя.
|
||||
|
||||
Ключ туннеля — **`deploy/tunnel/id_tunnel`** (приватный, в git не идёт). Его публичную
|
||||
часть добавь в `authorized_keys` пользователя `tunnel` на VPS. Один и тот же ключ годится
|
||||
для контейнерного туннеля (Pi/ПК) и для dev-туннеля `run.ps1`.
|
||||
Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя
|
||||
`tunnel` на VPS):
|
||||
- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет.
|
||||
- **ПК, временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер.
|
||||
- **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по
|
||||
умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`).
|
||||
|
||||
Настройка по шагам:
|
||||
1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`.
|
||||
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`.
|
||||
3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или
|
||||
ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`.
|
||||
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`.
|
||||
3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод,
|
||||
файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`.
|
||||
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
|
||||
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК.
|
||||
|
||||
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК,
|
||||
в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны.
|
||||
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на
|
||||
VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и
|
||||
`deploy/backup/` и шаблоны.
|
||||
|
||||
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
|
||||
> (`forbiddenstars.ru` и `forbidden-stars.ru`).
|
||||
@@ -57,3 +60,8 @@ HTTPS твоими сертификатами и проксирует трафи
|
||||
Шина событий — **in-memory**, рассчитана на один процесс (uvicorn `--workers 1`, как в
|
||||
контейнере). Если когда-нибудь поднимешь несколько воркеров/реплик — шину нужно вынести во
|
||||
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
|
||||
|
||||
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
|
||||
(10 с в `entrypoint.sh`, 2 с в `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
|
||||
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
|
||||
по `stop_grace_period`.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# В контекст сборки попадают ТОЛЬКО файлы образа. Всё остальное (в первую очередь приватный
|
||||
# ключ deploy/backup/id_backup, если он лежит здесь на ПК) в образ не попадёт.
|
||||
*
|
||||
!Dockerfile
|
||||
!fs-backup.sh
|
||||
!entrypoint.sh
|
||||
@@ -0,0 +1,34 @@
|
||||
# Контейнер бэкапов: 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
@@ -0,0 +1,60 @@
|
||||
#!/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
+164
-25
@@ -1,46 +1,185 @@
|
||||
# Raspberry Pi — ПРОД (`forbiddenstars.ru`)
|
||||
|
||||
Pi запускает приложение **и туннель** в Docker: `docker compose up` поднимает два сервиса —
|
||||
`app` (FastAPI + SPA) и `tunnel` (ssh-контейнер). У `app` **портов на хост нет**, наружу он
|
||||
доступен только через туннель-контейнер (`ssh -R 9000:app:8000` к VPS). Публичная точка —
|
||||
VPS, домен `forbiddenstars.ru`. Снаружи Pi недоступен (CGNAT) — туннель сам стучится наружу.
|
||||
На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл
|
||||
ключа туннеля не нужны:
|
||||
- образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`);
|
||||
- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64).
|
||||
|
||||
## 1. Ключ для туннеля
|
||||
Туннель-контейнер ходит на `tunnel@VPS` по ключу из `deploy/tunnel/id_tunnel` (gitignored).
|
||||
Создай ключ и добавь его pubkey в `authorized_keys` пользователя `tunnel` на VPS:
|
||||
`docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет),
|
||||
`tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic,
|
||||
см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель
|
||||
стучится наружу сам).
|
||||
|
||||
---
|
||||
|
||||
## 0. Предпосылки (один раз, не на Pi)
|
||||
1. **VPS настроен**: Caddy `forbiddenstars.ru` → `127.0.0.1:9000`, пользователь `tunnel`,
|
||||
сертификаты, страница-заглушка — см. `deploy/vps/README.md`.
|
||||
2. **Образы собраны и запушены в реестр** (на ПК с Docker Desktop):
|
||||
```powershell
|
||||
docker login gitea.arseniev.info
|
||||
.\scripts\build-push.ps1
|
||||
```
|
||||
Без этого `docker compose up` на Pi не найдёт образы в реестре.
|
||||
|
||||
## 1. Система и Docker на Pi
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f deploy/tunnel/id_tunnel -N ""
|
||||
ssh-copy-id -i deploy/tunnel/id_tunnel.pub tunnel@186.246.51.17
|
||||
# (или вручную: содержимое deploy/tunnel/id_tunnel.pub -> /home/tunnel/.ssh/authorized_keys на VPS)
|
||||
ssh pi@<ip-пая>
|
||||
sudo apt update && sudo apt -y full-upgrade
|
||||
sudo apt -y install curl openssh-client # base64/ssh-keygen уже есть в системе
|
||||
|
||||
curl -fsSL https://get.docker.com | sudo sh # Docker Engine + compose-плагин (arm64)
|
||||
sudo usermod -aG docker $USER
|
||||
sudo systemctl enable --now docker # автозапуск после ребута
|
||||
newgrp docker # применить группу (или перезайти по SSH)
|
||||
docker version && docker compose version # проверка
|
||||
```
|
||||
|
||||
## 2. .env
|
||||
### Лимиты памяти (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
|
||||
cp .env.example .env # заполни секреты; APP_ENV контейнер форсит в production
|
||||
docker info 2>&1 | grep -i "no memory limit"
|
||||
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
|
||||
```
|
||||
Проверь, что заданы параметры VPS (их читает сервис `tunnel`):
|
||||
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки
|
||||
в этом файле ломает загрузку) и перезагрузить Pi. Прод на время перезагрузки недоступен,
|
||||
контейнеры поднимутся сами:
|
||||
```bash
|
||||
sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak
|
||||
grep -q "cgroup_enable=memory" /boot/firmware/cmdline.txt || \
|
||||
sudo sed -i '1 s/$/ cgroup_enable=memory/' /boot/firmware/cmdline.txt
|
||||
cat /boot/firmware/cmdline.txt # одна строка, в конце cgroup_enable=memory
|
||||
sudo reboot
|
||||
```
|
||||
После перезагрузки `cgroup.controllers` содержит `memory`, а `docker info` не пишет
|
||||
`No memory limit support`. Если Pi не загрузился, верните бэкап: вставьте карту в ПК и на
|
||||
разделе `bootfs` замените `cmdline.txt` содержимым `cmdline.txt.bak`.
|
||||
|
||||
## 2. SSH-ключ для туннеля
|
||||
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
||||
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
||||
- **приватный** ключ → в `.env` как `TUNNEL_KEY_B64` (base64, одной строкой);
|
||||
- **публичный** (`.pub`) → в `authorized_keys` пользователя `tunnel` на VPS.
|
||||
|
||||
Сгенерировать прямо на Pi:
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f ~/fs_tunnel -N "" # создаст ~/fs_tunnel (приватный) и ~/fs_tunnel.pub
|
||||
```
|
||||
Добавить публичный ключ на VPS (Pi ходит наружу — это работает даже за CGNAT):
|
||||
```bash
|
||||
ssh-copy-id -i ~/fs_tunnel.pub tunnel@186.246.51.17
|
||||
# Если у tunnel нет пароля (только ключ) — добавь вручную через свой админ-доступ к VPS:
|
||||
# cat ~/fs_tunnel.pub # скопируй строку
|
||||
# на VPS: echo '<строка>' >> /home/tunnel/.ssh/authorized_keys
|
||||
```
|
||||
Закодировать приватный ключ в base64 **одной строкой** — это значение для `TUNNEL_KEY_B64`:
|
||||
```bash
|
||||
base64 -w0 ~/fs_tunnel; echo # выведет длинную строку без переносов — скопируй её целиком
|
||||
```
|
||||
> Альтернатива: если ключ уже есть на ПК (`deploy/tunnel/id_tunnel`) и его pubkey уже на VPS —
|
||||
> не плоди новый, закодируй тот:
|
||||
> `[Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\tunnel\id_tunnel")))`
|
||||
|
||||
После того как base64 вставлен в `.env`, файлы ключа на Pi можно удалить — ключ теперь в `.env`,
|
||||
а pubkey уже на VPS:
|
||||
```bash
|
||||
shred -u ~/fs_tunnel ~/fs_tunnel.pub # или просто rm
|
||||
```
|
||||
|
||||
## 3. Два файла: docker-compose.yml + .env
|
||||
```bash
|
||||
mkdir -p ~/forbidden-stars && cd ~/forbidden-stars
|
||||
# branch — main (или dev, если ещё не смёржено в main):
|
||||
curl -fsSLO https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/.env.example -o .env
|
||||
nano .env
|
||||
```
|
||||
Заполнить в `.env`:
|
||||
```
|
||||
SECRET_KEY=... # python3 -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||
ADMIN_USERNAME=... # логин/пароль секретной админки
|
||||
ADMIN_PASSWORD=...
|
||||
TELEGRAM_BOT_TOKEN=... # бот @BotFather; затем /setdomain → forbiddenstars.ru
|
||||
TELEGRAM_BOT_USERNAME=...
|
||||
VPS_TUNNEL_HOST=186.246.51.17
|
||||
VPS_TUNNEL_USER=tunnel
|
||||
TUNNEL_KEY_B64=... # длинная строка base64 из шага 2 (целиком, одной строкой)
|
||||
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по умолчанию
|
||||
IMAGE_TAG=latest
|
||||
```
|
||||
`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать.
|
||||
Блок `BACKUP_*` можно оставить пустым: бэкапы включаются позже, по
|
||||
[`deploy/backup/README.md`](../backup/README.md).
|
||||
|
||||
## 3. Запуск
|
||||
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
||||
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
||||
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
|
||||
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
|
||||
>
|
||||
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
|
||||
> его не сменить:
|
||||
> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi;
|
||||
> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env`
|
||||
> только при создании; без этого шага команда ниже увидит старый пароль);
|
||||
> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`.
|
||||
>
|
||||
> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются.
|
||||
> Логин (`ADMIN_USERNAME`) команда не меняет.
|
||||
|
||||
## 4. Запуск
|
||||
```bash
|
||||
docker compose up -d --build # поднимет app + tunnel
|
||||
docker compose ps # оба up; tunnel стартует после healthy app
|
||||
docker login gitea.arseniev.info # один раз, доступ к реестру
|
||||
docker compose up -d # pull_policy: always → тянет образы из реестра, без сборки
|
||||
docker compose ps # app healthy → поднимется tunnel
|
||||
docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@...
|
||||
```
|
||||
Открой `https://forbiddenstars.ru` — должен отдать прод.
|
||||
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`
|
||||
(если админа ещё нет).
|
||||
|
||||
## 5. Проверка
|
||||
- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку).
|
||||
- У @BotFather `/setdomain` → добавь `forbiddenstars.ru` (иначе Telegram-вход не заработает).
|
||||
- Админка: удержать «Меню» 10 с → `/admin/login`, войти логином/паролём из `.env`.
|
||||
|
||||
## Обновление
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
# Pi: docker compose exec backup fs-backup run --tag before-update # по желанию, если бэкапы включены
|
||||
# ПК: .\scripts\build-push.ps1
|
||||
# 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`
|
||||
контейнеры поднимутся сами (используют локальный образ, без повторного pull).
|
||||
|
||||
## Бэкапы
|
||||
Отдельный контейнер `backup` в том же `docker-compose.yml`: каждую ночь делает зашифрованный
|
||||
снимок БД, `uploads` и `achievements` на Pi и на VPS. Дополнительных файлов на Pi не нужно,
|
||||
всё настраивается блоком `BACKUP_*` в `.env`. Пока `BACKUP_PASSWORD` пуст, бэкапы выключены.
|
||||
|
||||
Пошаговая настройка, восстановление и действия при гибели Pi —
|
||||
[`deploy/backup/README.md`](../backup/README.md).
|
||||
|
||||
```bash
|
||||
docker compose exec backup fs-backup status # состояние
|
||||
docker compose exec backup fs-backup list # хронология снимков
|
||||
```
|
||||
|
||||
## Бэкап БД
|
||||
`scripts/backup.sh` (консистентная онлайн-копия SQLite; по cron на хосте). Том БД — `db-data`.
|
||||
> Не выполняйте `docker compose down -v`: флаг `-v` удаляет тома с данными и локальными бэкапами.
|
||||
|
||||
Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт
|
||||
502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в
|
||||
`authorized_keys` на VPS или не заданы `VPS_TUNNEL_*` в `.env`).
|
||||
## Если что-то не так
|
||||
- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel`
|
||||
(чаще: pubkey не в `authorized_keys` на VPS, пустой/битый `TUNNEL_KEY_B64`, либо на VPS занят
|
||||
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
||||
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
||||
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
||||
- `docker compose up` пишет «Your kernel does not support memory limit capabilities…
|
||||
Limitation discarded.» → лимиты памяти не работают: включите memory cgroup, раздел 1,
|
||||
«Лимиты памяти».
|
||||
|
||||
+12
-10
@@ -9,18 +9,20 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}"
|
||||
: "${VPS_TUNNEL_PORT:?VPS_TUNNEL_PORT is required}"
|
||||
UPSTREAM="${UPSTREAM:-app:8000}"
|
||||
|
||||
# Ключ должен быть смонтирован файлом. Если тут пусто/папка — Docker создал каталог,
|
||||
# потому что файла deploy/tunnel/id_tunnel на хосте нет.
|
||||
if [ ! -f /key/id_tunnel ]; then
|
||||
echo "[tunnel] ERROR: /key/id_tunnel is not a regular file."
|
||||
echo "[tunnel] Put your private key at deploy/tunnel/id_tunnel on the host (its pubkey must be in authorized_keys of tunnel@VPS)."
|
||||
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
|
||||
# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте).
|
||||
mkdir -p /root/.ssh
|
||||
KEY=/root/.ssh/id_tunnel
|
||||
if [ -n "${TUNNEL_KEY_B64:-}" ]; then
|
||||
echo "$TUNNEL_KEY_B64" | base64 -d > "$KEY"
|
||||
elif [ -f /key/id_tunnel ]; then
|
||||
cp /key/id_tunnel "$KEY"
|
||||
else
|
||||
echo "[tunnel] ERROR: нет ключа — задай TUNNEL_KEY_B64 в .env (base64) или примонтируй /key/id_tunnel."
|
||||
echo "[tunnel] base64 ключа без переносов: base64 -w0 deploy/tunnel/id_tunnel"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Копируем ключ и выставляем строгие права (с хоста права могут не годиться, особенно с Windows).
|
||||
mkdir -p /root/.ssh
|
||||
cp /key/id_tunnel /root/.ssh/id_tunnel
|
||||
chmod 600 /root/.ssh/id_tunnel
|
||||
chmod 600 "$KEY"
|
||||
|
||||
echo "[tunnel] -R ${VPS_TUNNEL_PORT}:${UPSTREAM} -> ${VPS_TUNNEL_USER}@${VPS_TUNNEL_HOST}"
|
||||
exec ssh -N \
|
||||
|
||||
+51
-12
@@ -1,23 +1,56 @@
|
||||
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||
#
|
||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
|
||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
||||
#
|
||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||
# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||
# приложение это учитывает (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 + промежуточные одним файлом).
|
||||
#
|
||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||
# буферизация задержат поток событий (реал-тайм перестанет работать).
|
||||
#
|
||||
# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет
|
||||
# (offline) подменяет ответ на свою страницу-заглушку (/etc/caddy/maintenance/maintenance.html),
|
||||
# вместо «зависшей загрузки» SPA. Файл заглушки — deploy/vps/maintenance.html.
|
||||
# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет (edge)
|
||||
# подменяет ответ на страницу-заглушку (/etc/caddy/maintenance/maintenance.html) вместо
|
||||
# «зависшей загрузки» SPA. Тот же сниппет запрещает кэш HTML-документа (Cache-Control: no-store),
|
||||
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
||||
# Файл заглушки — deploy/vps/maintenance.html.
|
||||
|
||||
# Заглушка «сайт недоступен» при недоступности апстрима. Импортируется в каждый сайт.
|
||||
(offline) {
|
||||
# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
|
||||
(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) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
||||
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
||||
@html header Accept *text/html*
|
||||
header @html Cache-Control "no-store"
|
||||
|
||||
# Туннель лёг (апстрим 502/503/504) → отдаём свою страницу-заглушку со статусом 503.
|
||||
handle_errors {
|
||||
@down expression `{http.error.status_code} in [502, 503, 504]`
|
||||
handle @down {
|
||||
@@ -37,13 +70,16 @@ forbiddenstars.ru {
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9000 {
|
||||
flush_interval -1
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
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 offline
|
||||
import edge
|
||||
}
|
||||
|
||||
forbidden-stars.ru {
|
||||
@@ -52,11 +88,14 @@ forbidden-stars.ru {
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9001 {
|
||||
flush_interval -1
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
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 offline
|
||||
import edge
|
||||
}
|
||||
|
||||
+29
-10
@@ -1,11 +1,11 @@
|
||||
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
|
||||
|
||||
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
|
||||
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test).
|
||||
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev).
|
||||
|
||||
```
|
||||
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
||||
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD
|
||||
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV
|
||||
```
|
||||
|
||||
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит
|
||||
@@ -51,7 +51,9 @@ install -d -m 700 -o tunnel -g tunnel /home/tunnel/.ssh
|
||||
install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys
|
||||
```
|
||||
Публичные ключи Pi и ПК добавишь в `/home/tunnel/.ssh/authorized_keys` на шагах настройки
|
||||
Pi (`deploy/pi/README.md`) и ПК (через `ssh-copy-id`).
|
||||
Pi (`deploy/pi/README.md`) и ПК (`deploy/tunnel/id_tunnel.pub` и/или ключ по умолчанию из
|
||||
`~/.ssh` для dev-туннеля). Ключи **дописывай** (`>>`), а не перезаписывай файл (`>`):
|
||||
иначе туннель другого хоста перестанет пускать.
|
||||
|
||||
## 5. Сертификаты
|
||||
|
||||
@@ -67,11 +69,11 @@ Caddy читает **PEM** (текст с `-----BEGIN CERTIFICATE-----`). Рас
|
||||
|
||||
Удобно собрать прямо на VPS — залей свои файлы и склей:
|
||||
```bash
|
||||
mkdir -p /etc/caddy/certs/forbidden-stars.ru /root/certs-tmp
|
||||
# с локальной машины (пример для домена forbidden-stars.ru):
|
||||
mkdir -p /etc/caddy/certs/forbidden-stars.ru /etc/caddy/certs/forbiddenstars.ru /root/certs-tmp
|
||||
# с локальной машины (пример для домена forbidden-stars.ru; имена файлов — свои):
|
||||
scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/
|
||||
# на VPS — собрать fullchain (leaf + промежуточный) и положить ключ:
|
||||
cat /root/certs-tmp/www_forbidden_stars_ru_2026_12_31.crt /root/certs-tmp/intermediate_pem_globalsign_ssl_dv_free_1.crt \
|
||||
cat /root/certs-tmp/forbidden-stars.crt /root/certs-tmp/intermediate.crt \
|
||||
> /etc/caddy/certs/forbidden-stars.ru/fullchain.pem
|
||||
cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem
|
||||
# то же для forbiddenstars.ru (свои crt/intermediate/key), затем права
|
||||
@@ -96,15 +98,32 @@ scp deploy/vps/maintenance.html root@186.246.51.17:/etc/caddy/maintenance/mainte
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
```
|
||||
> Заглушка живёт в сниппете `(offline)` Caddyfile: при ответе апстрима 502/503/504
|
||||
> Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` со статусом 503 и `Cache-Control: no-store`.
|
||||
> Заглушка живёт в сниппете `(edge)` Caddyfile (там же security-заголовки и `no-store` для
|
||||
> HTML): при ответе апстрима 502/503/504 Caddy отдаёт `/etc/caddy/maintenance/maintenance.html`
|
||||
> со статусом 503 и `Cache-Control: no-store`. Для `/api/events` (SSE) у каждого домена
|
||||
> отдельный `handle` без `encode` и с `flush_interval -1`.
|
||||
> Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен,
|
||||
> файл читается на каждый запрос).
|
||||
|
||||
## 7. Проверка
|
||||
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`).
|
||||
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК
|
||||
(лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`).
|
||||
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
|
||||
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
|
||||
(HTTP 503), это ожидаемо.
|
||||
|
||||
Логи Caddy: `journalctl -u caddy -f`.
|
||||
|
||||
## 8. Приём бэкапов с Pi (оффсайт-копии)
|
||||
Контейнер `backup` на Pi каждую ночь отправляет зашифрованный снимок (restic) на VPS по SFTP.
|
||||
Для этого на VPS нужен отдельный пользователь `fsbackup` **только для SFTP**: без shell,
|
||||
без туннелей, без пароля. Репозиторий лежит в `/srv/fs-backups/restic`, вне веб-корней
|
||||
Caddy, так что бэкапы не публичны.
|
||||
|
||||
> Имя не `backup`: в Debian/Ubuntu системный пользователь `backup` уже существует.
|
||||
|
||||
Пошаговая настройка: создание пользователя, ключ, `sshd_config.d/60-fs-backup.conf`,
|
||||
проверки — [`deploy/backup/README.md`, шаг 3](../backup/README.md#шаг-3-vps-пользователь-только-для-sftp).
|
||||
|
||||
Проверка приёма после первого бэкапа: `ls -la /srv/fs-backups/restic` (там `config`, `data`,
|
||||
`index`, `keys`, `snapshots`). Старые снимки удаляет сам контейнер с Pi по политике хранения.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ВРЕМЕННЫЙ ПРОД НА ПК — заглушка вместо Pi, пока он не готов.
|
||||
# Поднимает forbiddenstars.ru (слот VPS 9000) с твоего ПК: поведение production,
|
||||
# прод-данные в ПОСТОЯННЫХ томах (потом перенесёшь на Pi через backup/restore).
|
||||
#
|
||||
# Отличия от docker-compose.yml (прод на Pi):
|
||||
# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится;
|
||||
# • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК.
|
||||
# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck).
|
||||
#
|
||||
# Запуск: docker compose -f docker-compose.temp.yml up -d --build
|
||||
# Логи: docker compose -f docker-compose.temp.yml logs -f tunnel
|
||||
# Остановка: docker compose -f docker-compose.temp.yml down (тома сохраняются)
|
||||
#
|
||||
# ВАЖНО: слот 9000 общий с будущим прод-туннелем Pi. Перед запуском прода на Pi
|
||||
# ОСТАНОВИ этот контур (down), иначе оба будут драться за VPS:9000.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: forbidden-stars-temp
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: forbidden-stars:temp-prod # локальный образ x86 (на ПК); в реестр НЕ пушим
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
stop_grace_period: 30s
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
APP_ENV: production # ведём себя ровно как прод
|
||||
volumes:
|
||||
- db-data:/data
|
||||
- uploads-data:/data/uploads
|
||||
- achievements-data:/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: ${APP_MEM_LIMIT:-1g} # на ПК ресурсов больше, чем на Pi
|
||||
cpus: ${APP_CPUS:-2}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
# Туннель на ПРОД-слот: VPS:${VPS_TUNNEL_PORT:-9000} → app:8000 (домен forbiddenstars.ru).
|
||||
tunnel:
|
||||
build: ./deploy/tunnel
|
||||
image: forbidden-stars-tunnel:temp-prod
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST:?VPS_TUNNEL_HOST is required in .env}
|
||||
VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel}
|
||||
VPS_TUNNEL_PORT: ${VPS_TUNNEL_PORT:-9000} # прод-слот forbiddenstars.ru
|
||||
UPSTREAM: app:8000
|
||||
volumes:
|
||||
- ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro
|
||||
mem_limit: 64m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
uploads-data:
|
||||
achievements-data:
|
||||
@@ -1,62 +0,0 @@
|
||||
# Локальный «клон прода» в контейнере — для проверки прод-сборки на своей машине
|
||||
# (а не на Raspberry Pi). Тот же образ, что и прод, изолированные тома, единый .env.
|
||||
# Портов на хост НЕТ: тест виден ТОЛЬКО снаружи на https://forbidden-stars.ru через
|
||||
# сервис tunnel (контейнер ssh -R 9001:app:8000 на VPS).
|
||||
#
|
||||
# Запуск (обычно лаунчером при APP_ENV=test): docker compose -f docker-compose.test.yml up --build -d
|
||||
# Остановить и стереть данные: docker compose -f docker-compose.test.yml down -v
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: forbidden-stars:test
|
||||
container_name: forbidden-stars-test
|
||||
restart: "no"
|
||||
init: true
|
||||
env_file:
|
||||
- .env # единый .env (тот же, что у dev/prod); секреты не в git
|
||||
environment:
|
||||
# Окружение test: прод-клон, но отличимый от прода (см. config.is_test).
|
||||
# Форсим здесь, чтобы значение не зависело от APP_ENV внутри .env.
|
||||
APP_ENV: test
|
||||
# Портов на хост НЕТ: тест доступен только изнутри сети compose; наружу — через
|
||||
# сервис tunnel (ниже) на forbidden-stars.ru. Из LAN/localhost — недоступно.
|
||||
volumes:
|
||||
- db-data-test:/data # изолированные тестовые данные
|
||||
- uploads-data-test:/data/uploads
|
||||
- achievements-data-test:/data/achievements # определения ачивок (файлы)
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
mem_limit: 512m
|
||||
|
||||
# SSH reverse-туннель к VPS: forbidden-stars.ru (VPS:9001) -> app:8000 (по сети compose).
|
||||
# Ключ — ./deploy/tunnel/id_tunnel (gitignore), pubkey в authorized_keys у tunnel@VPS.
|
||||
# Внимание: слот 9001 общий с dev-туннелем (run.ps1); поднимай что-то одно за раз.
|
||||
tunnel:
|
||||
build: ./deploy/tunnel
|
||||
image: forbidden-stars-tunnel:test
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST}
|
||||
VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel}
|
||||
VPS_TUNNEL_PORT: "9001" # forbidden-stars.ru (dev/test)
|
||||
UPSTREAM: app:8000
|
||||
volumes:
|
||||
- ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro
|
||||
mem_limit: 64m
|
||||
|
||||
volumes:
|
||||
db-data-test:
|
||||
uploads-data-test:
|
||||
achievements-data-test:
|
||||
+91
-26
@@ -1,60 +1,125 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ПРОД на Raspberry Pi (forbiddenstars.ru) — единый compose: приложение + туннель.
|
||||
# На Pi нужны только ДВА файла: этот docker-compose.yml и .env. Репозиторий, сборка и
|
||||
# файл ключа не нужны: образы тянутся из реестра (pull_policy: always), а приватный ключ
|
||||
# туннеля лежит в .env (TUNNEL_KEY_B64, base64). build: оставлен для сборки/пуша на ПК
|
||||
# (scripts/build-push.*) — на Pi он не используется.
|
||||
#
|
||||
# Запуск/обновление: docker compose up -d (сам тянет свежие образы)
|
||||
# Логи: docker compose logs -f app (или: ... tunnel / backup)
|
||||
# Остановка: docker compose down (данные в томах сохраняются)
|
||||
# Бэкапы: docker compose exec backup fs-backup status (help — все команды)
|
||||
# инструкция: deploy/backup/README.md
|
||||
#
|
||||
# НИКОГДА не выполняйте на проде `docker compose down -v`: флаг -v удаляет тома — и данные
|
||||
# приложения, и локальный репозиторий бэкапов (останется только копия на VPS).
|
||||
#
|
||||
# Контейнер ВСЕГДА production: APP_ENV форсится здесь и игнорирует значение из .env.
|
||||
# Порты на хост НЕ публикуются — наружу приложение выставляет только сервис tunnel
|
||||
# (SSH reverse-туннель к VPS). Из LAN/localhost оно недоступно.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: forbidden-stars:latest
|
||||
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
|
||||
# .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
|
||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
||||
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
init: true # PID 1 пробрасывает сигналы → корректный SIGTERM
|
||||
stop_grace_period: 30s # время на чек-пойнт WAL SQLite перед остановкой
|
||||
env_file:
|
||||
- .env # секреты/настройки (один общий формат для dev и prod)
|
||||
- .env # единый источник настроек/секретов всего приложения
|
||||
environment:
|
||||
# Прод обособлен: контейнер ВСЕГДА production и ИГНОРИРУЕТ APP_ENV из .env
|
||||
# (хоть development, хоть test). БД=/data, STATIC_DIR=/app/static — из Dockerfile.
|
||||
APP_ENV: production
|
||||
# Портов на хост НЕТ: приложение доступно только изнутри сети compose.
|
||||
# Наружу его выставляет сервис tunnel (ниже) через VPS. Из LAN/localhost — недоступно.
|
||||
APP_ENV: production # прод обособлен: игнорируем APP_ENV из .env
|
||||
volumes:
|
||||
- db-data:/data # БД SQLite + WAL-сайдкары
|
||||
- uploads-data:/data/uploads # задел под фото/видео
|
||||
- achievements-data:/data/achievements # определения ачивок (файлы)
|
||||
- db-data:/data # БД SQLite + WAL-сайдкары
|
||||
- uploads-data:/data/uploads # медиа партий
|
||||
- achievements-data:/data/achievements # определения титулов (файлы)
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"
|
||||
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
|
||||
cpus: 1.5
|
||||
# Лимиты под Pi; переопределяются в .env. mem_limit действует, только если на Pi включён
|
||||
# memory cgroup — иначе Docker его молча игнорирует (deploy/pi/README.md, раздел 1).
|
||||
mem_limit: ${APP_MEM_LIMIT:-512m}
|
||||
cpus: ${APP_CPUS:-1.5}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
# SSH reverse-туннель к VPS: forbiddenstars.ru (VPS:9000) -> app:8000 (по сети compose).
|
||||
# Ключ — в ./deploy/tunnel/id_tunnel (gitignore), его pubkey в authorized_keys у tunnel@VPS.
|
||||
# VPS_TUNNEL_HOST/USER берутся из .env (на Pi заполни их там же).
|
||||
# SSH reverse-туннель: VPS:${VPS_TUNNEL_PORT:-9000} → app:8000 (домен forbiddenstars.ru).
|
||||
# Приватный ключ — в .env как TUNNEL_KEY_B64 (base64); pubkey — в authorized_keys у tunnel@VPS.
|
||||
tunnel:
|
||||
build: ./deploy/tunnel
|
||||
image: forbidden-stars-tunnel:latest
|
||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-tunnel:${IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST}
|
||||
VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST} # обязателен; tunnel.sh упадёт с ошибкой, если пуст
|
||||
VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel}
|
||||
VPS_TUNNEL_PORT: "9000" # forbiddenstars.ru (прод); у test — 9001
|
||||
VPS_TUNNEL_PORT: ${VPS_TUNNEL_PORT:-9000} # прод-слот (forbiddenstars.ru)
|
||||
UPSTREAM: app:8000
|
||||
volumes:
|
||||
- ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro
|
||||
TUNNEL_KEY_B64: ${TUNNEL_KEY_B64:-} # приватный ключ туннеля (base64), из .env
|
||||
mem_limit: 64m
|
||||
security_opt:
|
||||
- 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:
|
||||
db-data:
|
||||
uploads-data:
|
||||
achievements-data:
|
||||
backup-data:
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
# Новая рейтинговая система
|
||||
|
||||
> **Статус:** утверждено (#22), реализовано в #23 (`backend/app/services/scoring.py`).
|
||||
> Решения владельца по открытым вопросам (2026-09-14) внесены — [раздел 9](#9-решения-владельца).
|
||||
> **Приложение:** [`simulate.py`](simulate.py) — эталонная реализация формул, все примеры
|
||||
> этого документа (с `assert`), симуляция и перебор коэффициентов. Только стандартная
|
||||
> библиотека Python, вывод воспроизводим:
|
||||
>
|
||||
> ```bash
|
||||
> python docs/rating/simulate.py # примеры + сравнение систем (≈1.5 мин)
|
||||
> python docs/rating/simulate.py --grid # примеры + подбор коэффициентов (≈7 мин)
|
||||
> ```
|
||||
|
||||
## Коротко
|
||||
|
||||
Сейчас рейтинг — среднее очков за место. Он не видит ни силы соперников, ни хода партии,
|
||||
а первое место в дуэли и за столом на пятерых стоит одинаково.
|
||||
|
||||
Предлагается **многопользовательский Elo с множителем отрыва**:
|
||||
|
||||
- **Разбор на дуэли.** Партия раскладывается на пары игроков. Каждая пара — дуэль, исход
|
||||
которой сравнивается с ожидаемым по разнице рейтингов: победа над сильным даёт больше,
|
||||
чем над слабым.
|
||||
- **Множитель отрыва.** Размер изменения зависит от того, *как* партия сыграна: на каком
|
||||
раунде закончилась, какой отрыв по целям и мирам, какой тип победы. Размер стола тоже
|
||||
влияет, но слабее темпа.
|
||||
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
||||
задачи «60 против 40» в ней — 1600 против 1400.
|
||||
- **Выбывшие между собой не сравниваются.** Все они проиграли, а миров у них нет: пара двух
|
||||
выбывших в расчёт не входит. Сила соперников при этом важна, как и прежде (4.4).
|
||||
- **Монотонность.** Единоличный победитель и выбывшие всегда движутся в свою сторону: первый
|
||||
не теряет рейтинг, вторые его не получают. Внутри ничьей невыбывших — за 1-е или
|
||||
последнее место — пара может сдвинуть рейтинг в любую сторону (4.9).
|
||||
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
||||
признаки берутся нейтральными.
|
||||
|
||||
На синтетической лиге система лучше текущей и чистого Elo во всех сценариях, где отрыв
|
||||
действительно связан с силой игроков. Например, после 10 партий она правильнее упорядочивает
|
||||
игроков: ρ = 0.807 против 0.769 у текущей. Там, где отрыв — чистый шум, она уступает
|
||||
чистому Elo около 0.1 п.п. точности. Подробности — в [разделе 7](#7-проверка-на-симуляции).
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Как считается сейчас и что с этим не так](#1-как-считается-сейчас-и-что-с-этим-не-так)
|
||||
2. [Исходные данные: правила игры](#2-исходные-данные-правила-игры)
|
||||
3. [Выбор модели](#3-выбор-модели)
|
||||
4. [Формулы](#4-формулы)
|
||||
5. [Требования → решения](#5-требования--решения)
|
||||
6. [Примеры расчётов](#6-примеры-расчётов)
|
||||
7. [Проверка на симуляции](#7-проверка-на-симуляции)
|
||||
8. [Что потребуется в реализации (#23)](#8-что-потребуется-в-реализации-23)
|
||||
9. [Решения владельца](#9-решения-владельца)
|
||||
|
||||
---
|
||||
|
||||
## 1. Как считается сейчас и что с этим не так
|
||||
|
||||
`backend/app/services/scoring.py`. За партию на N игроков участник получает League Points:
|
||||
|
||||
```math
|
||||
\text{points} = \frac{N - \text{place} - (\text{tie}-1)/2}{N-1}
|
||||
```
|
||||
|
||||
1-е место — 1.0, последнее — 0.0; равные места делят очки. Рейтинг — сглаженное среднее
|
||||
с 10 «виртуальными» партиями по 0.5, в топ попадают игроки от 10 партий:
|
||||
|
||||
```math
|
||||
\text{score} = \frac{10 \cdot 0.5 + \sum \text{points}}{10 + \text{games}} \cdot 100
|
||||
```
|
||||
|
||||
Проблемы на цифрах:
|
||||
|
||||
| # | Проблема | Пример |
|
||||
|---|---|---|
|
||||
| 1 | **Сила соперника не учитывается.** | Победа над лидером топа и над новичком — одинаковые 1.0. Кто играет в основном со слабыми, копит рейтинг быстрее. В симуляции с тремя группами разной силы текущая система упорядочивает игроков с ρ = 0.525, предложенная — с 0.644 (раздел 7). |
|
||||
| 2 | **Размер стола не учитывается.** | 1-е место в дуэли — 1.0, 1-е место за столом на пятерых — тоже 1.0, хотя обыграны четверо. |
|
||||
| 3 | **Ход партии не учитывается.** | Разгром на 3-м раунде (2:0 по целям, 8:2 по мирам) и победа на тай-брейке по мирам в конце 8-го раунда — одинаковые 1.0. |
|
||||
| 4 | **Рейтинг помнит всю историю с одним весом.** | Игрок, проигравший первые 10 дуэлей и выигравший следующие 10, имеет (5 + 10) / 30 · 100 = **50** — как середняк, хотя сейчас сильнее всех. В сценарии «рост» симуляции ρ после 10 партий у текущей системы 0.732, у предложенной 0.774. |
|
||||
|
||||
## 2. Исходные данные: правила игры
|
||||
|
||||
По справочнику (стр. 8, 11, 16) и уточнениям владельца в #22.
|
||||
|
||||
**Победа.** Игрок, собравший маркеры целей в количестве, равном числу игроков N, побеждает.
|
||||
Цели собираются в фазе обновления, поэтому партия заканчивается на границе раунда.
|
||||
Если к концу последнего раунда никто не набрал N, побеждает тот, у кого больше целей.
|
||||
|
||||
**Тай-брейки** (от менее близкой партии к более близкой) — это и есть типы победы
|
||||
в приложении:
|
||||
|
||||
| Тип (`win_reason`) | Когда | Близость партии |
|
||||
|---|---|---|
|
||||
| `objectives` — по целям | больше всех целей | обычная |
|
||||
| `worlds` — по мирам | цели поровну, больше дружественных миров | близкая |
|
||||
| `plastic` — по пластику | цели и миры поровну, больше отрядов на поле | очень близкая |
|
||||
| `resources` — по ресурсам | всё выше поровну; **домашнее правило** | самая близкая |
|
||||
| `last_standing` — последний выживший | все соперники устранены (нет миров); **новая причина** (решение владельца, раздел 9) | разгром |
|
||||
|
||||
Если поровну вообще всё, победа общая (в приложении — ничья за 1-е место).
|
||||
|
||||
**Лимит раундов `R_max`.** По правилам 8. **Домашнее правило:** за столом на 5–6 игроков
|
||||
играется 9 раундов. Правило включается галочкой в настройках группы.
|
||||
|
||||
**Миры.** В тексте #22 они названы «системами». По справочнику *система* — это целый тайл
|
||||
из четырёх областей, а область с планетой — *мир*. Дальше используется термин справочника.
|
||||
Размер поля и «честная доля» миров на игрока:
|
||||
|
||||
| N | Поле | Тайлов | Миров на поле (×2.2) | Миров на игрока `W(N)/N` |
|
||||
|---|---|---|---|---|
|
||||
| 2 | 2×3 | 6 | 13.2 | 6.60 |
|
||||
| 3 | 3×3 | 9 | 19.8 | 6.60 |
|
||||
| 4 | 3×4 | 12 | 26.4 | 6.60 |
|
||||
| 5 | 4×4 | 16 | 35.2 | 7.04 |
|
||||
| 6 | 4×5 | 20 | 44.0 | 7.33 |
|
||||
|
||||
## 3. Выбор модели
|
||||
|
||||
Что нужно от модели:
|
||||
|
||||
- учитывать силу соперников (треб. 3);
|
||||
- работать для столов на 2–6 игроков с ничьими и выбывшими;
|
||||
- принимать дополнительные признаки партии (треб. 1, 2, 4, 5);
|
||||
- считаться вручную — чтобы игроки могли проверить, почему рейтинг изменился именно так;
|
||||
- работать на малых данных: десятки игроков и сотни партий;
|
||||
- обходиться без внешних зависимостей: вся реализация — пара десятков строк.
|
||||
|
||||
| Модель | Сила соперника | 2–6 игроков | Признаки партии | Ручной расчёт | Вывод |
|
||||
|---|---|---|---|---|---|
|
||||
| League Points (сейчас) | нет | да | только место | да | не выполняет треб. 1–5 |
|
||||
| Elo, парный | да | да, через пары | через множитель K | да | **база** |
|
||||
| Glicko-2 | да, плюс неопределённость | только через пары, как Elo; нужны рейтинговые периоды | нет | тяжело | сложность без выигрыша на наших объёмах |
|
||||
| TrueSkill / OpenSkill | да, плюс неопределённость | да, нативно | нет — только порядок мест | нет | не принимает отрыв без самодельных надстроек |
|
||||
|
||||
Выбран **парный Elo с множителем отрыва**. Отрыв умножает K, а фактический результат пары
|
||||
остаётся 1/0.5/0. Можно было бы вшить отрыв в сам результат (например, близкая победа =
|
||||
0.6), но тогда фаворит, выигравший близко, *терял бы* рейтинг за победу. Множитель K
|
||||
сохраняет монотонность: победа всегда в плюс, отрыв влияет только на размер.
|
||||
|
||||
## 4. Формулы
|
||||
|
||||
### 4.1. Шкала
|
||||
|
||||
Стартовый рейтинг **R₀ = 1500**, масштаб **D = 400**: разница 400 пунктов означает шансы 10:1.
|
||||
Это классическая шкала Elo (решение владельца, раздел 9).
|
||||
|
||||
С нынешним топом (около 50) она не совпадает. Для сопоставления чисел в этом документе
|
||||
используется соответствие `R = 10·score + 1000`: 50 ↔ 1500, 60 ↔ 1600. Пример «60 против 40»
|
||||
из #22 — это 1600 против 1400. Формулы от выбора шкалы не зависят. Если пересчитать рейтинги
|
||||
по этому соответствию, а `D` и `K` умножить на 10, ожидания, порядок игроков и качество
|
||||
прогноза не изменятся — в 10 раз вырастут только изменения рейтинга и его разброс.
|
||||
`simulate.py` это проверяет: на одних и тех же сезонах обе шкалы дают одинаковые точность,
|
||||
Brier и ρ.
|
||||
|
||||
### 4.2. Ожидаемый результат пары
|
||||
|
||||
```math
|
||||
E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
||||
```
|
||||
|
||||
`E_ab` — вероятность, что `a` окажется выше `b`. При 1600 против 1400 — 0.760, при равных — 0.500.
|
||||
|
||||
### 4.3. Фактический результат пары
|
||||
|
||||
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
||||
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`),
|
||||
но между собой не сравниваются (4.4).
|
||||
|
||||
### 4.4. Изменение рейтинга
|
||||
|
||||
```math
|
||||
\Delta R_i = \frac{K_i \cdot G(N)}{N-1} \sum_{j \ne i} M_{ij} \, (S_{ij} - E_{ij})
|
||||
```
|
||||
|
||||
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
||||
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
||||
|
||||
**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба
|
||||
выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они
|
||||
все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего
|
||||
с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна.
|
||||
Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая
|
||||
пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший
|
||||
получал рейтинг за счёт сильных выбывших.
|
||||
|
||||
### 4.5. Коэффициент K — скорость изменения
|
||||
|
||||
```math
|
||||
K_i = K_{\min} + (K_{\max} - K_{\min}) \cdot \max\!\left(0,\; 1 - \frac{n_i}{n_K}\right)
|
||||
```
|
||||
|
||||
`n_i` — сколько завершённых партий игрок сыграл до этой. Новичок стартует с `K_max = 64`,
|
||||
к 20-й партии K линейно спускается до `K_min = 16`. Так новичок быстро находит свой
|
||||
уровень, а рейтинг опытного игрока не скачет от одной партии.
|
||||
|
||||
Эта схема заменяет нынешние «10 виртуальных партий». Минимум партий для топа
|
||||
(`MIN_GAMES = 10`) сохраняется. К 10-й партии предложенная система упорядочивает игроков
|
||||
лучше текущей: ρ 0.807 против 0.769 в сценарии «сигнал» (раздел 7).
|
||||
|
||||
### 4.6. Вес размера стола (треб. 2)
|
||||
|
||||
```math
|
||||
G(N) = 1 + w_N \cdot \frac{N-2}{4}, \qquad w_N = 0.5
|
||||
```
|
||||
|
||||
`G` = 1.0 для дуэли, 1.25 для четверых, 1.5 для шестерых.
|
||||
|
||||
### 4.7. Множитель отрыва пары
|
||||
|
||||
Признаки пары (a — выше или наравне с b), каждый нормирован в [0, 1]:
|
||||
|
||||
| Признак | Формула | Для каких пар | Типичное значение μ |
|
||||
|---|---|---|---|
|
||||
| темп `τ` (треб. 1) | `(R_max − раунд) / (R_max − 1)` | только пары с победителем | `1/(R_max − 1)` — конец в предпоследнем раунде |
|
||||
| отрыв по целям `o` (треб. 4) | `max(0, цели_a − цели_b) / N` | все | 0.5 |
|
||||
| отрыв по мирам `w` (треб. 4) | `min(1, max(0, миры_a − миры_b) / (W(N)/N))` | все | 0.5 |
|
||||
|
||||
Для пар с равными местами разница берётся по модулю. При победе `last_standing` отрыв
|
||||
победителя по целям считается равным 1: все соперники устранены, сколько бы маркеров
|
||||
у них ни было.
|
||||
|
||||
```math
|
||||
A_{ab} = 1 + w_\tau (\tau - \mu_\tau) + w_o (o - 0.5) + w_w (w - 0.5)
|
||||
```
|
||||
|
||||
```math
|
||||
M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
||||
```
|
||||
|
||||
- **Центрирование.** Благодаря вычитанию μ партия с типичными признаками получает `M = 1` —
|
||||
то есть обычный Elo. Разгром поднимает множитель, близкая партия его снижает.
|
||||
- **Ограничение.** `clamp` ставит страховку: одна партия не может весить больше чем вдвое
|
||||
или меньше чем вдвое против обычной.
|
||||
- **Близость по типу победы `c`** (треб. 5) действует только на пары с победителем: тип
|
||||
победы описывает борьбу за первое место, а не за второе или третье.
|
||||
|
||||
| `win_reason` | `objectives` | `worlds` | `plastic` | `resources` | `last_standing` |
|
||||
|---|---|---|---|---|---|
|
||||
| `c` | 1.00 | 0.85 | 0.70 | 0.60 | 1.00 |
|
||||
|
||||
### 4.8. Партии без новых полей
|
||||
|
||||
У партий из истории (и у любых, где поле не заполнено) нет раунда, целей или миров.
|
||||
Недостающий признак подставляется **типичным значением μ**: его слагаемое в `A` равно нулю.
|
||||
Тип победы в истории есть, поэтому близость `c` работает всегда. Для старой партии формула
|
||||
сводится к чистому Elo с учётом размера стола и типа победы. Заполнять историю задним числом
|
||||
не обязательно.
|
||||
|
||||
### 4.9. Свойства
|
||||
|
||||
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
||||
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже:
|
||||
его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей
|
||||
невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший
|
||||
1-е место со слабым, может потерять.
|
||||
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
||||
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
||||
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
||||
не больше чем на 0.8 пункта, в сценарии «рост» — на −7 при разбросе силы игроков ±140.
|
||||
- **Ограниченность.** Изменение за партию не больше `K·G·2.0`: 32 у ветерана в дуэли,
|
||||
128 у новичка в дуэли, 192 у новичка за столом на шестерых. Это теоретические пределы
|
||||
для разгромной победы, которой почти никто не ждал (`E ≈ 0`); против равных — вдвое меньше.
|
||||
- **Детерминизм.** Рейтинг — функция упорядоченной истории партий. Пересчёт с нуля всегда
|
||||
даёт тот же результат.
|
||||
|
||||
### 4.10. Коэффициенты
|
||||
|
||||
| Параметр | Значение | Откуда |
|
||||
|---|---|---|
|
||||
| `R₀`, `D` | 1500, 400 | шкала (4.1), решение владельца |
|
||||
| `K_max`, `K_min`, `n_K` | 64, 16, 20 | перебор (7.4): выигрыш на «сигнале» без потерь на «шуме» |
|
||||
| `w_N` (стол) | 0.5 | требование «слабее темпа»; в переборе 0.25–0.5 равноценны |
|
||||
| `w_τ` (темп) | 1.0 | требование 1 («значительно ценнее»); в переборе безопасен до 1.0, вред — с 2.0 |
|
||||
| `w_o` (цели) | 0.5 | требование 4; равен весу миров, пока нет данных, что один из признаков информативнее (7.4) |
|
||||
| `w_w` (миры) | 0.5 | требование 4; в переборе 0.5 — лучший вес единственного признака отрыва |
|
||||
| `c` (близость) | 1 / 0.85 / 0.7 / 0.6 / 1 | требование 5, экспертная оценка по порядку тай-брейков, **утверждена владельцем**; симуляция не подтверждает и не опровергает (7.5) |
|
||||
| `clamp` | [0.5, 2.0] | страховка от выбросов |
|
||||
|
||||
## 5. Требования → решения
|
||||
|
||||
| Требование из #22 | Механизм | Эффект (ветеран против равного, дуэль) |
|
||||
|---|---|---|
|
||||
| 1. Темп: 4 цели за 2 раунда ценнее, чем к концу 8-го | признак `τ`, вес 1.0 — самый большой из весов | победа на 3-м раунде +11.2, на 8-м +5.5 — **вдвое** (пример 2) |
|
||||
| 2. Размер стола, но слабее темпа | `G(N)`, вес 0.5 | 1-е место: дуэль +8.0, пятеро +11.0, шестеро +12.0 — **до ×1.5**, меньше, чем ×2 у темпа (примеры 3, 6) |
|
||||
| 3. Разница рейтингов с соперником | ожидание `E` | 1600 побеждает 1400: +3.8; 1400 побеждает 1600: +12.2 — **втрое** больше (пример 1) |
|
||||
| 4. Цели и миры на конец партии | признаки `o`, `w` | стол на 4: пары с выбывшими весят 1.25–1.5, пара лидеров — 0.7 (пример 5) |
|
||||
| 5. Тип победы | близость `c` | без деталей: по целям +8.0 → по мирам +6.8 → по пластику +5.6 → по ресурсам +4.8 (пример 4) |
|
||||
| Веса параметров (из треб. 2) | единый множитель `M` с весами | весь диапазон по отрыву: от +3.4 (самая близкая партия) до +16.0 (разгром) — **×4.7** (пример 4) |
|
||||
|
||||
## 6. Примеры расчётов
|
||||
|
||||
Все игроки опытные (40 партий, `K = 16`), если не сказано иное. Числа совпадают
|
||||
с выводом `simulate.py` — скрипт проверяет их через `assert`. Промежуточные значения
|
||||
здесь округлены. В приложении рейтинг показывается целым числом, а в расчёте хранится
|
||||
без округления (раздел 8).
|
||||
|
||||
### Пример 1. Дуэль 1600 против 1400 (треб. 3)
|
||||
|
||||
Партия из старой истории: только места и тип `objectives`, так что `M = 1`.
|
||||
|
||||
- `E(A выше B) = 1 / (1 + 10^(−200/400)) = 1 / (1 + 0.316) = 0.760`.
|
||||
- **1a. Побеждает сильный A:** `ΔR_A = 16 · 1 · (1 − 0.760) = +3.84`, у B −3.84.
|
||||
- **1b. Побеждает слабый B:** `ΔR_B = 16 · 1 · (1 − 0.240) = +12.16`, у A −12.16.
|
||||
|
||||
Неожиданная победа приносит втрое больше ожидаемой.
|
||||
|
||||
### Пример 2. Быстрая и медленная победа (треб. 1)
|
||||
|
||||
Равные (1500 и 1500), дуэль на поле 2×3, победа по целям 2:1, миры 5:4. Разница — только раунд.
|
||||
|
||||
| | Раунд 3 (2a) | Раунд 8 (2b) |
|
||||
|---|---|---|
|
||||
| темп `τ = (8 − r)/7` | 0.714 | 0.000 |
|
||||
| `w_τ(τ − 1/7)` | +0.571 | −0.143 |
|
||||
| цели `o = 1/2` → `0.5·(0.5 − 0.5)` | 0 | 0 |
|
||||
| миры `w = 1/6.6 = 0.152` → `0.5·(0.152 − 0.5)` | −0.174 | −0.174 |
|
||||
| `A = M` | 1.397 | 0.683 |
|
||||
| `ΔR_A = 16 · M · (1 − 0.5)` | **+11.18** | **+5.46** |
|
||||
|
||||
### Пример 3. Размер стола (треб. 2)
|
||||
|
||||
Все по 1500, партии без деталей (`M = 1`).
|
||||
|
||||
- **3a. Дуэль:** `ΔR_1 = 16 · 1 · 0.5 = +8.00`.
|
||||
- **3b. Стол на 5:** `G(5) = 1 + 0.5·3/4 = 1.375`, множитель перед суммой — `16·1.375/4 = 5.5`.
|
||||
|
||||
| Место | Σ (S − E) по 4 соперникам | ΔR | Сейчас (League Points) |
|
||||
|---|---|---|---|
|
||||
| 1 | 4·0.5 = 2.0 | **+11.00** | 1.00 |
|
||||
| 2 | −0.5 + 3·0.5 = 1.0 | +5.50 | 0.75 |
|
||||
| 3 | 0 | 0.00 | 0.50 |
|
||||
| 4 | −1.0 | −5.50 | 0.25 |
|
||||
| 5 | −2.0 | −11.00 | 0.00 |
|
||||
|
||||
### Пример 4. Тип победы и близость партии (треб. 5)
|
||||
|
||||
Равные, дуэль.
|
||||
|
||||
| Вариант | τ | o | w | A | c | M | ΔR победителя |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| по целям, без деталей (= 3a) | μ | μ | μ | 1.000 | 1.00 | 1.000 | +8.00 |
|
||||
| 4a: по мирам, без деталей | μ | μ | μ | 1.000 | 0.85 | 0.850 | +6.80 |
|
||||
| 4b: по пластику, без деталей | μ | μ | μ | 1.000 | 0.70 | 0.700 | +5.60 |
|
||||
| 4c: по ресурсам, без деталей | μ | μ | μ | 1.000 | 0.60 | 0.600 | +4.80 |
|
||||
| 4d: по мирам на 8-м раунде, цели 2:2, миры 6:5 | 0 | 0 | 0.152 | 0.433 → **0.5** | 0.85 | 0.425 | **+3.40** |
|
||||
| 4e: разгром — 3-й раунд, цели 2:0, миры 8:2 | 0.714 | 1 | 0.909 | 2.026 → **2.0** | 1.00 | 2.000 | **+16.00** |
|
||||
|
||||
В 4d и 4e сработала страховка `clamp`.
|
||||
|
||||
### Пример 5. Стол на 4 с выбывшими (треб. 4)
|
||||
|
||||
Партия закончилась на 7-м раунде по целям. `G(4) = 1.25`, множитель перед суммой —
|
||||
`16·1.25/3 = 6.67`, честная доля миров — 6.6.
|
||||
|
||||
| Игрок | Рейтинг | Место | Цели | Миры |
|
||||
|---|---|---|---|---|
|
||||
| A | 1550 | 1 | 4 | 8 |
|
||||
| B | 1500 | 2 | 3 | 7 |
|
||||
| C | 1480 | 3 (выбыл) | 1 | 0 |
|
||||
| D | 1450 | 3 (выбыл) | 0 | 0 |
|
||||
|
||||
Темп `τ = 1/7` совпадает с типичным, его слагаемое равно 0.
|
||||
|
||||
| Пара | S | E | o | w | A = M | M·(S − E) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A–B | 1 | 0.571 | 1/4 | 1/6.6 = 0.152 | 1 − 0.125 − 0.174 = **0.701** | 0.300 |
|
||||
| A–C | 1 | 0.599 | 3/4 | 1 | 1 + 0.125 + 0.25 = **1.375** | 0.551 |
|
||||
| A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 |
|
||||
| B–C | 1 | 0.529 | 2/4 | 1 | 1 + 0 + 0.25 = **1.250** | 0.589 |
|
||||
| B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 |
|
||||
| C–D | — | — | — | — | оба выбыли — пара не считается (4.4) | 0 |
|
||||
|
||||
- `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
|
||||
- `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
|
||||
- `ΔR_C = 6.67 · (−0.551 − 0.589)` = **−7.60**
|
||||
- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53**
|
||||
|
||||
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
||||
от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба
|
||||
проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего.
|
||||
|
||||
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
||||
|
||||
Все по 1500, партия закончилась **на 8-м раунде**, других деталей нет.
|
||||
`G(6) = 1.5`, множитель перед суммой — `16·1.5/5 = 4.8`.
|
||||
|
||||
| | 6a: хоумрул включён, `R_max = 9` | 6b: хоумрул выключен, `R_max = 8` |
|
||||
|---|---|---|
|
||||
| темп `τ` | (9 − 8)/8 = 0.125 | (8 − 8)/7 = 0 |
|
||||
| типичный `μ_τ` | 1/8 = 0.125 | 1/7 = 0.143 |
|
||||
| `M` пар с победителем | 1.000 — обычная партия | 0.857 — затянутая |
|
||||
| ΔR по местам 1…6 | +12.00, +7.20, +2.40, −2.40, −7.20, −12.00 | +10.29, +7.54, +2.74, −2.06, −6.86, −11.66 |
|
||||
|
||||
Конец на 8-м раунде при лимите 9 — это «предпоследний раунд», то есть типичная партия.
|
||||
При лимите 8 — затянутая партия, и победа весит меньше. Поэтому лимит раундов снимается
|
||||
в партию при её создании (раздел 8).
|
||||
|
||||
### Пример 7. Новичок против ветерана
|
||||
|
||||
Оба по 1500; A — новичок (0 партий, `K = 64`), B — 40 партий (`K = 16`). A побеждает.
|
||||
|
||||
`ΔR_A = 64 · 0.5` = **+32**, `ΔR_B = 16 · (−0.5)` = **−8**.
|
||||
|
||||
О силе новичка ещё ничего не известно, поэтому его рейтинг двигается быстро. Ветеран
|
||||
теряет как за обычное поражение от равного.
|
||||
|
||||
## 7. Проверка на симуляции
|
||||
|
||||
### 7.1. Модель лиги
|
||||
|
||||
У настоящих партий пока нет раундов, целей и миров, поэтому система проверяется на
|
||||
синтетической лиге, где «истинная» сила игроков известна.
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Игроки | 12 со старта, ещё по 2 на 1/3 и 2/3 сезона; активность у каждого своя (0.5–1.5) |
|
||||
| Сезон | 300 партий; хоумрул 9 раундов включён в половине сезонов |
|
||||
| Размер стола | 2 — 45%, 3 — 25%, 4 — 20%, 5 — 7%, 6 — 3% |
|
||||
| Сила | `θ ~ N(0, 150)`; в шкале рейтинга ≈ ±140 |
|
||||
| Производительность в партии | `θ + N(0, 225)` — кубы, карты, ошибки; места — по производительности |
|
||||
| Детали партии | из отрыва производительности: раунд (чем больше отрыв, тем раньше конец), цели, миры, тип победы, выбывание. Получается 81% побед по целям, 10% по мирам, 4% по пластику, 1.4% по ресурсам, 3% последний выживший; чаще всего конец на 7–8 раунде |
|
||||
|
||||
Сценарии:
|
||||
|
||||
| Сценарий | Что проверяет |
|
||||
|---|---|
|
||||
| **сигнал** | базовый: отрыв связан с разницей сил |
|
||||
| **шум** | места те же, но величина отрыва случайна и с силой не связана — сколько система теряет, если признаки ничего не говорят |
|
||||
| **клубы** | три группы разной силы (−150 / 0 / +150), 90% партий внутри своей — умеет ли общий рейтинг «сшить» группы |
|
||||
| **рост** | новички стартуют слабее на 0–200 и догоняют с опытом (×1/e за 15 партий) — успевает ли рейтинг за ростом игрока |
|
||||
|
||||
### 7.2. Метрики
|
||||
|
||||
- **Точность** — доля пар без ничьих во второй половине сезона, где *до* партии рейтинг
|
||||
выше у занявшего место выше. Потолок — тот же прогноз по истинной силе.
|
||||
- **Brier** — `(1 − E)²` по тем же парам, меньше — лучше. Показывает, насколько честны
|
||||
сами вероятности; есть только у Elo-систем.
|
||||
- **ρ** — ранговая корреляция Спирмена рейтинга на конец сезона с истинной силой
|
||||
(игроки с 10+ партиями).
|
||||
- **ρ@k** — то же сразу после k-й партии игрока: как быстро рейтинг «находит» игрока.
|
||||
- **RMSE** — ошибка рейтинга относительно истинной силы в пунктах шкалы.
|
||||
- **Наклон** — регрессия рейтинга на истинную силу: 1.0 — разброс честный, меньше —
|
||||
рейтинги сжаты к середине.
|
||||
- **|ΔR|** — средний модуль изменения за партию у игроков с 20+ партиями (волатильность).
|
||||
У League Points рейтинг в шкале 0–100, его |ΔR| приведён к шкале 1500 умножением на 10
|
||||
(соответствие из 4.1).
|
||||
|
||||
Сравнение идёт на одних и тех же сезонах (парные разности). Коэффициенты подбирались
|
||||
на других сезонах (7.4), так что это проверка вне выборки подбора.
|
||||
|
||||
### 7.3. Результаты: 200 сезонов на сценарий
|
||||
|
||||
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
||||
так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны
|
||||
с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь
|
||||
в третьем знаке, у остальных систем цифры прежние.
|
||||
|
||||
**Сигнал** (потолок точности 0.6843)
|
||||
|
||||
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 |
|
||||
| Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 47.4 | 0.81 | 5.24 |
|
||||
| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.712** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 |
|
||||
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.783 | 0.864 | 47.2 | 0.79 | 5.90 |
|
||||
|
||||
**Клубы** (потолок 0.6308)
|
||||
|
||||
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 |
|
||||
| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 |
|
||||
| **Предложенная** | **0.6101** | 0.2336 | **0.645** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
|
||||
| Предложенная, без новых полей | 0.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 |
|
||||
|
||||
**Рост** (потолок 0.6864)
|
||||
|
||||
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 |
|
||||
| Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 48.0 | 0.81 | 5.25 |
|
||||
| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.775** | **0.850** | **42.7** | **0.95** | 5.96 |
|
||||
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.5 | 0.79 | 5.91 |
|
||||
|
||||
**Шум** (потолок 0.6894)
|
||||
|
||||
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 |
|
||||
| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 |
|
||||
| Предложенная | 0.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 |
|
||||
| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 |
|
||||
|
||||
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
||||
|
||||
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
||||
|---|---|---|---|
|
||||
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. |
|
||||
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. |
|
||||
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. |
|
||||
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
||||
|
||||
Выводы:
|
||||
|
||||
1. **Если отрыв отражает силу** (а требования #22 исходят именно из этого), предложенная
|
||||
система лучше обеих альтернатив по всем метрикам качества. Сильнее всего она выигрывает
|
||||
в скорости: после 5 партий ρ = 0.71 против 0.66 у текущей, после 10 — 0.81 против 0.77.
|
||||
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
||||
отрываются от середняков.
|
||||
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
||||
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы
|
||||
у неё стоит рядом с чемпионом сильной.
|
||||
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
||||
цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях
|
||||
она чистому Elo не уступает.
|
||||
4. **История без новых полей** считается как чистый Elo с учётом стола и типа победы.
|
||||
По точности и Brier она не хуже чистого Elo ни в одном сценарии.
|
||||
5. **Абсолютные разности точности малы** (доли процента), потому что партия Forbidden Stars
|
||||
сама по себе сильно случайна: даже знание истинной силы угадывает порядок пары лишь
|
||||
в 68% случаев. Все системы близки к этому потолку. Качество рейтинга лучше видно
|
||||
по ρ@k и RMSE, чем по точности.
|
||||
|
||||
### 7.4. Подбор коэффициентов
|
||||
|
||||
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
||||
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
||||
Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не
|
||||
переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3).
|
||||
|
||||
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
||||
Выгоден высокий K новичка и низкий K ветерана.
|
||||
|
||||
| `K_max` \ `K_min` (спуск за 20 партий) | 16 | 24 | 32 |
|
||||
|---|---|---|---|
|
||||
| 48 | 0.21697 | 0.21708 | 0.21775 |
|
||||
| 64 | 0.21649 | 0.21675 | 0.21751 |
|
||||
| 96 | **0.21639** | 0.21669 | 0.21747 |
|
||||
| 128 | 0.21682 | 0.21703 | 0.21775 |
|
||||
| 160 | 0.21748 | 0.21755 | 0.21819 |
|
||||
|
||||
Лучший вариант со спуском за 10 партий — 0.21676 (96 / 24). Для «чистого Elo»
|
||||
в сравнении 7.3 взяты K = 96 / 16 / 20. Чистый Elo не использует миры, поэтому поле 2×3
|
||||
на этот этап не повлияло.
|
||||
|
||||
**Этап 2. Веса отрыва** при K этапа 1: 162 комбинации (`w_N` ∈ {0, 0.25, 0.5};
|
||||
`w_τ`, `w_o`, `w_w` ∈ {0, 0.5, 1}; близость да/нет); сценарии «сигнал», «клубы», «рост».
|
||||
|
||||
Без множителя (чистый Elo с K этапа 1): Brier **0.21846**, на «шуме» **0.21017**.
|
||||
Лучшие комбинации:
|
||||
|
||||
| Brier | Brier «шум» | `w_N` | `w_τ` | `w_o` | `w_w` | близость |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 0.21797 | 0.20991 | 0.25 | 0 | 0 | 0.5 | да |
|
||||
| 0.21801 | 0.20985 | 0.5 | 0 | 0 | 0.5 | да |
|
||||
| 0.21802 | 0.21009 | 0 | 0 | 0 | 0.5 | да |
|
||||
| 0.21804 | 0.21005 | 0.25 | 0.5 | 0 | 0.5 | да |
|
||||
| 0.21804 | 0.20994 | 0.25 | 0 | 0 | 0.5 | нет |
|
||||
| … | | | | | | |
|
||||
| 0.21995 | | | | | | худшая комбинация |
|
||||
|
||||
Лучшие комбинации выигрывают у отсутствия множителя около 0.0005, худшая проигрывает
|
||||
0.0015. Оптимум очень пологий: первые десять вариантов умещаются в 0.0001.
|
||||
|
||||
Перебор оставляет **один** признак отрыва из трёх. Это ожидаемо: в генераторе раунд, цели
|
||||
и миры выводятся из одного и того же отрыва производительности, второй признак не добавляет
|
||||
информации и лишь увеличивает разброс.
|
||||
Реальная игра так не устроена: в ней ранний конец, счёт целей и контроль миров — разные
|
||||
стороны партии. Поэтому веса признаков заданы требованиями #22 в пределах безопасной
|
||||
зоны (этап 4), а не взяты из вершины перебора.
|
||||
|
||||
**Этап 3. K для предложенных весов.** Множитель в среднем чуть больше 1, поэтому K нужен
|
||||
меньше, чем у чистого Elo. Спуск за 20 партий:
|
||||
|
||||
| `K_max` | `K_min` | Brier (сигнальные) | Brier «шум» | Brier с поправкой на автокорреляцию |
|
||||
|---|---|---|---|---|
|
||||
| 48 | 12 | **0.21759** | 0.21063 | 0.21755 |
|
||||
| 48 | 16 | 0.21778 | 0.21049 | 0.21773 |
|
||||
| 64 | 12 | 0.21779 | 0.21026 | 0.21771 |
|
||||
| **64** | **16** | 0.21797 | **0.21022** | 0.21788 |
|
||||
| 80 | 16 | 0.21838 | 0.21028 | 0.21826 |
|
||||
|
||||
- **Выбор 64 / 16.** Против чистого Elo он даёт −0.00049 на сигнальных сценариях и
|
||||
+0.00005 на «шуме». Вариант 48 / 12 выигрывает больше (−0.00087), но на «шуме»
|
||||
проигрывает +0.00046. Выбран вариант, который не теряет, если гипотеза ТЗ о значении
|
||||
отрыва не подтвердится.
|
||||
- **Поправка на автокорреляцию** (FiveThirtyEight: фаворит закономерно побеждает с большим
|
||||
отрывом, и без поправки его рейтинг раздувается) даёт около 0.0001. В формулу она не
|
||||
включена: лишняя сложность для ручного расчёта при нулевом эффекте.
|
||||
|
||||
**Этап 4. Чувствительность.** Меняется один параметр, остальные — как в предложении
|
||||
(Brier 0.21797, «шум» 0.21022). ρ@10 здесь — среднее по трём сценариям, включая «клубы»,
|
||||
поэтому оно ниже, чем в 7.3.
|
||||
|
||||
| Параметр | Значение | Brier (сигнальные) | ρ@10 | Brier «шум» |
|
||||
|---|---|---|---|---|
|
||||
| `w_N` | 0 / 0.25 / **0.5** / 1.0 | 0.21803 / 0.21796 / **0.21797** / 0.21819 | 0.663 / 0.668 / **0.674** / 0.680 | 0.21072 / 0.21041 / **0.21022** / 0.21011 |
|
||||
| `w_τ` | 0 / 0.25 / 0.5 / **1.0** / 2.0 | 0.21771 / 0.21775 / 0.21781 / **0.21797** / 0.21826 | 0.672 / 0.672 / 0.673 / **0.674** / 0.667 | 0.20988 / 0.20995 / 0.21003 / **0.21022** / 0.21050 |
|
||||
| `w_o` | 0 / 0.25 / **0.5** / 1.0 / 2.0 | 0.21767 / 0.21778 / **0.21797** / 0.21838 / 0.21892 | 0.666 / 0.668 / **0.674** / 0.674 / 0.671 | 0.21003 / 0.21010 / **0.21022** / 0.21042 / 0.21073 |
|
||||
| `w_w` | 0 / 0.25 / **0.5** / 1.0 / 2.0 | 0.21781 / 0.21785 / **0.21797** / 0.21827 / 0.21873 | 0.667 / 0.671 / **0.674** / 0.673 / 0.669 | 0.21003 / 0.21011 / **0.21022** / 0.21037 / 0.21054 |
|
||||
| `c` | нет / **предложенная** / вдвое сильнее | 0.21799 / **0.21797** / 0.21799 | 0.673 / **0.674** / 0.672 | 0.21016 / **0.21022** / 0.21031 |
|
||||
|
||||
Итог:
|
||||
|
||||
- Веса в диапазоне 0–1 безопасны: изменение вдвое сдвигает Brier не больше чем на 0.00041
|
||||
(`w_o` 0.5 → 1.0). Вред начинается с 2.0 — поэтому ни один вес не выше 1.
|
||||
- Вес размера стола полезен: от 0 до 0.5 растёт ρ@10 и падает Brier на «шуме».
|
||||
- Близость по типу победы в симуляции нейтральна (±0.00002).
|
||||
|
||||
### 7.5. Что симуляция доказывает и что нет
|
||||
|
||||
- **Доказывает:** формулы корректны и устойчивы, не раздувают рейтинг, быстро сходятся,
|
||||
правильно «сшивают» группы разной силы. Выбранные веса лежат в пологой области: изменение
|
||||
любого веса вдвое в любую сторону сдвигает Brier не больше чем на 0.00041. Если признаки
|
||||
партии окажутся бесполезны, потеря мала.
|
||||
- **Не доказывает:**
|
||||
- что в *реальных* партиях Forbidden Stars ранний конец, отрыв по целям и мирам связаны
|
||||
с разницей сил так, как заложено в генераторе. Это допущение, на котором стоит и само
|
||||
ТЗ;
|
||||
- какой из признаков отрыва (раунд, цели, миры) информативнее: в генераторе все три
|
||||
выводятся из одного отрыва и дублируют друг друга.
|
||||
|
||||
Проверить это можно только на реальных данных после внедрения #23 (раздел 8,
|
||||
«Калибровка»).
|
||||
- **Коэффициенты близости `c`** симуляция не подтверждает и не опровергает: при записанных
|
||||
целях и мирах тип победы почти не добавляет информации. Значения — экспертная оценка
|
||||
по порядку тай-брейков. Их главная роль — старые партии, где тип победы — единственный
|
||||
признак хода игры.
|
||||
|
||||
## 8. Что потребуется в реализации (#23)
|
||||
|
||||
Изменение **ломающее** (`Compat/Breaking`): у всех игроков меняются числа рейтинга и,
|
||||
вероятно, порядок в топе.
|
||||
|
||||
### Данные (миграция `0014_*`)
|
||||
|
||||
| Где | Поле | Тип | Смысл |
|
||||
|---|---|---|---|
|
||||
| `groups` | `nine_rounds_rule` | bool, default false | галочка «9 раундов при 5–6 игроках» |
|
||||
| `matches` | `nine_rounds_rule` | bool, default false | **снимок** настройки группы при создании партии: смена настройки не должна переписывать историю (пример 6) |
|
||||
| `matches` | `end_round` | int NULL, `1 ≤ end_round ≤ R_max` | раунд, в котором партия закончилась |
|
||||
| `match_participants` | `objectives` | int NULL, ≥ 0 | маркеры целей на конец партии |
|
||||
| `match_participants` | `worlds` | int NULL, ≥ 0 | дружественные миры на конец партии; у выбывшего 0 |
|
||||
| `matches.win_reason` | + `last_standing` | CHECK | новая причина победы (раздел 9) |
|
||||
|
||||
`R_max` в партии не хранится, а вычисляется: `9`, если `nine_rounds_rule` и `N ≥ 5`, иначе `8`.
|
||||
Число участников может поменяться при правке партии, а снимок правила — нет.
|
||||
|
||||
Миграция идемпотентна, как `0003`: ALTER только при отсутствии столбца, `render_as_batch`
|
||||
для CHECK. Новые столбцы задним числом не заполняются: NULL — это «нет данных», и формулы
|
||||
его учитывают (4.8).
|
||||
|
||||
**Бэкфилл нужен только для `last_standing`.** У завершённых партий, где невыбывший участник
|
||||
ровно один, миграция ставит `win_reason = last_standing`. Тогда старые партии не нарушают
|
||||
правило из раздела «Ввод» при правке, а в рейтинге считаются так же, как новые: отрыв
|
||||
победителя по целям = 1 (4.7). Бэкфилл идемпотентен: повторный запуск ничего не меняет.
|
||||
|
||||
### Ввод
|
||||
|
||||
- Форма завершения (`MatchDetailPage`, `match_service.finish_match`, черновик
|
||||
`MatchFinishDraft`) и админская правка (`AdminMatchEdit`): раунд окончания и у каждого
|
||||
участника цели и миры. Поля необязательные — пропуск лучше выдумки.
|
||||
- Серверная валидация — только диапазоны и явные противоречия: у выбывшего миры = 0;
|
||||
раунд ≤ `R_max`. Подсказки о согласованности (тип `worlds` при неравных целях лидеров
|
||||
и т.п.) лучше показывать предупреждением, а не отказом.
|
||||
- Настройки группы: галочка рядом с дополнениями (`PUT /groups/{id}/expansions` или
|
||||
отдельный `PATCH`).
|
||||
- **Причина `last_standing`** (решение владельца):
|
||||
- правило — «невыбывший участник ровно один» ⇔ `win_reason = last_standing`;
|
||||
- в форме завершения и в админской правке причина проставляется автоматически, как только
|
||||
все участники, кроме одного, отмечены выбывшими. Выбор причины при этом заблокирован;
|
||||
- вернули второго невыбывшего — причина сбрасывается, её нужно выбрать заново;
|
||||
- в выпадающем списке причин `last_standing` нет: выбрать её вручную нельзя;
|
||||
- сервер проверяет то же правило в `finish_match` и при правке результатов: несовпадение —
|
||||
ошибка валидации. Черновик формы (`PUT /matches/{id}/finish-draft`) правило не проверяет,
|
||||
он хранит незаконченный ввод.
|
||||
|
||||
### Отображение
|
||||
|
||||
- Рейтинг показывается **целым числом**; в расчёте значения хранятся без округления, иначе
|
||||
ошибка округления накапливается по цепочке партий.
|
||||
- Общий топ (`OverallStatsPage.tsx`): столбец «Поб» и сортировка по победам убираются, чтобы
|
||||
четырёхзначный рейтинг поместился в строку; «Очки» переименовываются в «Рейтинг».
|
||||
Для единообразия — «Очки (рейтинг)» в `ProfileStatsCard.tsx` и заголовок «Очки игроков
|
||||
(рейтинг)» в `HelpPage.tsx`.
|
||||
- Подпись причины `last_standing` в карточке партии и истории — «последний выживший».
|
||||
|
||||
### Расчёт
|
||||
|
||||
- Рейтинг — функция упорядоченной истории, поэтому он **пересчитывается проигрыванием**
|
||||
завершённых партий по порядку (`played_at`, `finished_at`, `id`), а не агрегатом SQL.
|
||||
Данных мало: сотни партий, микросекунды на пару. Существующий принцип «считается вживую»
|
||||
сохраняется, кэш можно ввести позже с инвалидацией по уже существующим SSE-событиям.
|
||||
- **Одна цепочка** (решение владельца 2026-09-15, #80): рейтинг у игрока один — по всем
|
||||
партиям приложения, K — по всем его партиям. Отдельного группового рейтинга нет:
|
||||
на странице группы игры, победы, винрейт и среднее место считаются по партиям группы,
|
||||
а рейтинг и статус «Новичок» — общие. Первая версия реализации (#23) держала две
|
||||
цепочки, общую и групповую, — это оказалось неинтуитивно (раздел 9).
|
||||
- Правка или удаление прошлой партии автоматически меняет всё после неё: при пересчёте
|
||||
с нуля отдельной логики не нужно.
|
||||
- Эталон — `rate_match` в `simulate.py`. Примеры из раздела 6 стоит перенести в тесты
|
||||
бэкенда как есть.
|
||||
- Смежные метрики на старых очках места:
|
||||
- «лучшая партия» в профиле (`user_match_list(best_only)`) → партия с наибольшим ΔR;
|
||||
- «лучшая/худшая фракция» → средний `S − E` на фракции: насколько игрок на ней
|
||||
выступает выше ожидания, без привязки к рейтингу;
|
||||
- `win_rate`, `avg_place`, «форма» — без изменений.
|
||||
|
||||
### Что ломается для пользователей
|
||||
|
||||
- Числа рейтинга у всех меняются: шкала другая (около 1500 вместо около 50), и это другая
|
||||
величина — не «средний процент очков», а сила относительно соперников.
|
||||
- Порядок в топе может измениться — в первую очередь у тех, кто играл в основном со слабыми
|
||||
или сильными соперниками.
|
||||
- Рейтинг новичка после одной партии меняется заметно сильнее, чем раньше: +32 за обычную
|
||||
победу над равным, до +64 за разгром (теоретический предел — 128).
|
||||
- В общем топе пропадает столбец побед (win rate остаётся).
|
||||
- **Предложение:** разовое уведомление всем игрокам и короткое пояснение «как считается
|
||||
рейтинг» на странице топа.
|
||||
|
||||
### Калибровка после внедрения
|
||||
|
||||
Когда наберётся ~100 партий с заполненными раундом, целями и мирами:
|
||||
|
||||
- типичные значения `μ` заменить средними по реальным партиям;
|
||||
- повторить перебор весов из `simulate.py` на реальной истории: критерий — Brier прогноза
|
||||
следующей партии;
|
||||
- проверить главное допущение: есть ли у ранних побед и большого отрыва связь с последующими
|
||||
результатами игроков.
|
||||
|
||||
Коэффициенты — константы в одном модуле (как сейчас `scoring.py`): калибровка — это правка
|
||||
констант и пересчёт, без миграций.
|
||||
|
||||
### Справка
|
||||
|
||||
Формула текущего рейтинга и пороги продублированы текстом на странице справки
|
||||
(`frontend/src/pages/HelpPage.tsx`). При реализации #23 её нужно переписать под новую
|
||||
систему: шкала, от чего зависит изменение рейтинга, `MIN_GAMES`, причина `last_standing`.
|
||||
|
||||
## 9. Решения владельца
|
||||
|
||||
Первая версия документа выносила шесть вопросов на решение. Ответы владельца —
|
||||
[комментарий к PR #65](https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/pulls/65#issuecomment-3466)
|
||||
(2026-09-14). Там же и в [#22](https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/issues/22#issuecomment-3380)
|
||||
уточнено поле дуэли: 2×3, а не 2×2 — исправлено в разделе 2, примерах и симуляции.
|
||||
|
||||
| # | Вопрос | Решение | Что изменилось в документе |
|
||||
|---|---|---|---|
|
||||
| 1 | Шкала отображения: «Elo/10» (старт 50) или классические 1500 | **1500.** В общем топе убрать столбец побед, «Очки» переименовать в «Рейтинг» | 4.1 и все числа примеров и таблиц; раздел 8, «Отображение» |
|
||||
| 2 | Коэффициенты близости по типам победы (1 / 0.85 / 0.7 / 0.6 / 1) | **Согласованы** | 4.10 — отмечены как утверждённые |
|
||||
| 3 | Новая причина победы `last_standing` | **Добавить.** Ставится автоматически, когда невыбывший ровно один, и не меняется, пока невыбывших меньше двух; в списке выбора её нет | раздел 2; раздел 8 — «Данные» (бэкфилл) и «Ввод» |
|
||||
| 4 | Ввод миров на конец партии | **Оставить** | без изменений: `w_w = 0.5` |
|
||||
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
||||
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
||||
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
|
||||
| 8 | Сравнивать ли выбывших между собой (после ревью, #87 → #91, 2026-09-18) | **Нет.** Все они проиграли и одинаково слабы в этой партии, миров у них нет; при расчёте победителя сила соперников по-прежнему важна | 4.3, 4.4, 4.9, пример 5, итоги 7.3 (подбор 7.4 не переигрывался) |
|
||||
|
||||
Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных —
|
||||
после внедрения #23 (раздел 8, «Калибровка»).
|
||||
@@ -0,0 +1,868 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Эталонная реализация и симуляция предложенной рейтинговой системы (#22).
|
||||
|
||||
Скрипт — приложение к docs/rating/rating-system.md:
|
||||
|
||||
1. Формулы документа в коде (раздел «Эталонная реализация»). #23 может сверять
|
||||
с ними свою реализацию.
|
||||
2. Пошаговые примеры документа с assert на числа: документ и код не разъедутся.
|
||||
Плюс проверка, что шкала (1500 или прежние 50) не влияет на качество прогноза.
|
||||
3. Синтетическая лига: игроки со скрытой «истинной» силой, партии на 2–6 человек.
|
||||
Детали партии (раунд, цели, миры, тип победы) выводятся из отрыва
|
||||
по производительности. На одних и тех же партиях сравниваются текущий League
|
||||
Points, чистый парный Elo и предложенная система.
|
||||
4. Перебор весов (--grid).
|
||||
|
||||
Только стандартная библиотека и фиксированные seed — вывод воспроизводим.
|
||||
|
||||
python docs/rating/simulate.py # примеры + сравнение систем (≈1.5 мин)
|
||||
python docs/rating/simulate.py --grid # примеры + перебор K и весов (≈7 мин)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import random
|
||||
import statistics
|
||||
import sys
|
||||
from dataclasses import dataclass, replace
|
||||
from itertools import combinations
|
||||
|
||||
# ═══ Правила игры ═══════════════════════════════════════════════════════════════
|
||||
|
||||
# Размер поля в тайлах по числу игроков (дуэль — 2×3, 6 игроков — 4×5; уточнения владельца в #22).
|
||||
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
|
||||
WORLDS_PER_TILE = 2.2
|
||||
BASE_ROUNDS = 8
|
||||
# Хоумрул группы: при 5–6 игроках играется 9 раундов.
|
||||
EXTENDED_ROUNDS = 9
|
||||
EXTENDED_MIN_PLAYERS = 5
|
||||
|
||||
|
||||
def worlds_on_board(n: int) -> float:
|
||||
return BOARD_TILES[n] * WORLDS_PER_TILE
|
||||
|
||||
|
||||
def fair_worlds(n: int) -> float:
|
||||
"""«Честная доля» миров на игрока — масштаб для разницы миров."""
|
||||
return worlds_on_board(n) / n
|
||||
|
||||
|
||||
def max_rounds(n: int, nine_rounds: bool) -> int:
|
||||
return EXTENDED_ROUNDS if nine_rounds and n >= EXTENDED_MIN_PLAYERS else BASE_ROUNDS
|
||||
|
||||
|
||||
# ═══ Эталонная реализация ═════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@dataclass
|
||||
class Seat:
|
||||
player: str
|
||||
place: int
|
||||
objectives: int | None = None # маркеры целей на конец партии
|
||||
worlds: int | None = None # дружественные миры на конец партии
|
||||
eliminated: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Match:
|
||||
seats: list[Seat]
|
||||
win_reason: str | None = None
|
||||
round: int | None = None # раунд, в котором партия закончилась
|
||||
nine_rounds: bool = False # снимок настройки группы на момент партии
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Params:
|
||||
# Классическая шкала Elo (решение владельца в PR #65): старт 1500, разница 400 = шансы 10:1.
|
||||
r0: float = 1500.0 # стартовый рейтинг
|
||||
d: float = 400.0 # масштаб логистики
|
||||
k_max: float = 64.0 # K новичка (0 партий)
|
||||
k_min: float = 16.0 # K опытного игрока
|
||||
k_games: int = 20 # за сколько партий K линейно спускается от k_max к k_min
|
||||
w_table: float = 0.0 # вес размера стола (треб. 2)
|
||||
w_tempo: float = 0.0 # вес темпа победы (треб. 1)
|
||||
w_obj: float = 0.0 # вес разницы целей (треб. 4)
|
||||
w_worlds: float = 0.0 # вес разницы миров (треб. 4)
|
||||
# Близость партии по типу победы (треб. 5) — множитель пар с победителем.
|
||||
closeness: tuple[tuple[str, float], ...] = ()
|
||||
# «Типичные» значения признаков: партия с ними получает множитель 1,
|
||||
# отсутствующий признак подставляется типичным (= нейтральным).
|
||||
mu_obj: float = 0.5
|
||||
mu_worlds: float = 0.5
|
||||
m_min: float = 0.5
|
||||
m_max: float = 2.0
|
||||
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
||||
# Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму
|
||||
# (решение владельца, #91). У систем для сравнения — как было.
|
||||
skip_eliminated_pairs: bool = False
|
||||
|
||||
def closeness_for(self, reason: str | None) -> float:
|
||||
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
||||
|
||||
|
||||
def mu_tempo(rmax: int) -> float:
|
||||
"""Типичный темп: партия закончилась в предпоследнем раунде."""
|
||||
return 1.0 / (rmax - 1)
|
||||
|
||||
|
||||
def expected(r_a: float, r_b: float, d: float) -> float:
|
||||
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
|
||||
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / d))
|
||||
|
||||
|
||||
def k_factor(games: int, p: Params) -> float:
|
||||
left = max(0.0, 1.0 - games / p.k_games)
|
||||
return p.k_min + (p.k_max - p.k_min) * left
|
||||
|
||||
|
||||
def table_weight(n: int, p: Params) -> float:
|
||||
return 1.0 + p.w_table * (n - 2) / 4.0
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def pair_multiplier(
|
||||
m: Match, a: Seat, b: Seat, r_a: float, r_b: float, p: Params
|
||||
) -> tuple[float, dict]:
|
||||
"""Множитель отрыва пары; a — выше или наравне с b. Возвращает (M, разбор)."""
|
||||
n = len(m.seats)
|
||||
tie = a.place == b.place
|
||||
winner_pair = a.place == 1
|
||||
parts: dict[str, float] = {}
|
||||
add = 1.0
|
||||
|
||||
def diff(x: int, y: int) -> float:
|
||||
return abs(x - y) if tie else max(0, x - y)
|
||||
|
||||
if winner_pair:
|
||||
rmax = max_rounds(n, m.nine_rounds)
|
||||
mu = mu_tempo(rmax)
|
||||
tempo = mu if m.round is None else (rmax - m.round) / (rmax - 1)
|
||||
parts["tempo"] = tempo
|
||||
add += p.w_tempo * (tempo - mu)
|
||||
if winner_pair and m.win_reason == "last_standing":
|
||||
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
|
||||
elif a.objectives is not None and b.objectives is not None:
|
||||
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
|
||||
else:
|
||||
obj = p.mu_obj
|
||||
parts["obj"] = obj
|
||||
add += p.w_obj * (obj - p.mu_obj)
|
||||
if a.worlds is not None and b.worlds is not None:
|
||||
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
|
||||
else:
|
||||
wor = p.mu_worlds
|
||||
parts["worlds"] = wor
|
||||
add += p.w_worlds * (wor - p.mu_worlds)
|
||||
|
||||
if p.autocorr and add > 1.0 and not tie:
|
||||
# Поправка FiveThirtyEight: 2.2 / (0.001·ΔElo + 2.2).
|
||||
kappa = 2.2 / (0.001 * (r_a - r_b) + 2.2)
|
||||
parts["kappa"] = kappa
|
||||
add = 1.0 + (add - 1.0) * kappa
|
||||
parts["additive"] = add
|
||||
mult = _clamp(add, p.m_min, p.m_max)
|
||||
close = p.closeness_for(m.win_reason) if winner_pair else 1.0
|
||||
parts["closeness"] = close
|
||||
return mult * close, parts
|
||||
|
||||
|
||||
def rate_match(
|
||||
ratings: dict[str, float],
|
||||
games: dict[str, int],
|
||||
m: Match,
|
||||
p: Params,
|
||||
trace: list | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Изменения рейтинга участников партии. Рейтинги/счётчики не мутирует."""
|
||||
n = len(m.seats)
|
||||
g = table_weight(n, p)
|
||||
r = {s.player: ratings.get(s.player, p.r0) for s in m.seats}
|
||||
k = {s.player: k_factor(games.get(s.player, 0), p) for s in m.seats}
|
||||
delta = {s.player: 0.0 for s in m.seats}
|
||||
for a, b in combinations(m.seats, 2):
|
||||
if p.skip_eliminated_pairs and a.eliminated and b.eliminated:
|
||||
continue
|
||||
if a.place > b.place:
|
||||
a, b = b, a
|
||||
s_ab = 0.5 if a.place == b.place else 1.0
|
||||
e_ab = expected(r[a.player], r[b.player], p.d)
|
||||
mult, parts = pair_multiplier(m, a, b, r[a.player], r[b.player], p)
|
||||
x = mult * (s_ab - e_ab)
|
||||
delta[a.player] += k[a.player] * g / (n - 1) * x
|
||||
delta[b.player] -= k[b.player] * g / (n - 1) * x
|
||||
if trace is not None:
|
||||
trace.append(
|
||||
{"a": a.player, "b": b.player, "S": s_ab, "E": e_ab, "M": mult, **parts}
|
||||
)
|
||||
return delta
|
||||
|
||||
|
||||
# ═══ Системы для сравнения ════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class LeaguePoints:
|
||||
"""Текущая система (backend/app/services/scoring.py): сглаженное среднее очков за место."""
|
||||
|
||||
probabilistic = False
|
||||
# Рейтинг в шкале 0–100; |ΔR| сравнивается с Elo в пересчёте R_Elo = 10·score + 1000.
|
||||
move_scale = 10.0
|
||||
PRIOR_GAMES = 10
|
||||
PRIOR_MEAN = 0.5
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sum: dict[str, float] = {}
|
||||
self.games: dict[str, int] = {}
|
||||
|
||||
def rating(self, player: str) -> float:
|
||||
g = self.games.get(player, 0)
|
||||
return (self.PRIOR_GAMES * self.PRIOR_MEAN + self.sum.get(player, 0.0)) / (
|
||||
self.PRIOR_GAMES + g
|
||||
) * 100
|
||||
|
||||
def update(self, m: Match) -> dict[str, float]:
|
||||
n = len(m.seats)
|
||||
tie = {}
|
||||
for s in m.seats:
|
||||
tie[s.place] = tie.get(s.place, 0) + 1
|
||||
before = {s.player: self.rating(s.player) for s in m.seats}
|
||||
for s in m.seats:
|
||||
pts = (n - s.place - (tie[s.place] - 1) / 2) / (n - 1)
|
||||
self.sum[s.player] = self.sum.get(s.player, 0.0) + pts
|
||||
self.games[s.player] = self.games.get(s.player, 0) + 1
|
||||
return {s.player: self.rating(s.player) - before[s.player] for s in m.seats}
|
||||
|
||||
|
||||
class Elo:
|
||||
"""Парный многопользовательский Elo; с нулевыми весами — «чистый» Elo."""
|
||||
|
||||
probabilistic = True
|
||||
move_scale = 1.0
|
||||
|
||||
def __init__(self, p: Params) -> None:
|
||||
self.p = p
|
||||
self.r: dict[str, float] = {}
|
||||
self.games: dict[str, int] = {}
|
||||
|
||||
def rating(self, player: str) -> float:
|
||||
return self.r.get(player, self.p.r0)
|
||||
|
||||
def update(self, m: Match) -> dict[str, float]:
|
||||
delta = rate_match(self.r, self.games, m, self.p)
|
||||
for pl, dv in delta.items():
|
||||
self.r[pl] = self.rating(pl) + dv
|
||||
self.games[pl] = self.games.get(pl, 0) + 1
|
||||
return delta
|
||||
|
||||
|
||||
# ═══ Генератор синтетической лиги ═════════════════════════════════════════════
|
||||
|
||||
SIGMA_SKILL = 150.0 # разброс «истинной» силы игроков
|
||||
SIGMA_PERF = 225.0 # шум производительности в отдельной партии (кубы, карты, ошибки)
|
||||
# Истинная сила в шкале рейтинга: Φ(Δθ/(σ√2)) ≈ логистика с масштабом d=400 при ΔR ≈ 0.93·Δθ.
|
||||
SKILL_TO_RATING = 1.702 * 400 / (math.log(10) * SIGMA_PERF * math.sqrt(2))
|
||||
TABLE_SIZES = ((2, 0.45), (3, 0.25), (4, 0.20), (5, 0.07), (6, 0.03))
|
||||
ELIM_Z = 2.3 # отставание (в σ), при котором игрок может выбыть
|
||||
ELIM_P = 0.35 # вероятность выбывания при таком отставании
|
||||
|
||||
|
||||
def _choice_weighted(rng: random.Random, pairs) -> int:
|
||||
x = rng.random() * sum(w for _, w in pairs)
|
||||
for v, w in pairs:
|
||||
x -= w
|
||||
if x <= 0:
|
||||
return v
|
||||
return pairs[-1][0]
|
||||
|
||||
|
||||
def generate_match(
|
||||
rng: random.Random,
|
||||
skill: dict[str, float],
|
||||
players: list[str],
|
||||
nine_rounds: bool,
|
||||
informative: bool,
|
||||
) -> Match:
|
||||
"""Партия: места — по производительности, детали — по отрыву.
|
||||
|
||||
informative=False — сценарий «шум»: места те же, но величина отрыва (а значит
|
||||
раунд, цели, миры и тип победы) не связана с силой игроков."""
|
||||
n = len(players)
|
||||
perf = {pl: skill[pl] + rng.gauss(0, SIGMA_PERF) for pl in players}
|
||||
order = sorted(players, key=perf.get, reverse=True)
|
||||
if informative:
|
||||
z = {pl: (perf[order[0]] - perf[pl]) / SIGMA_PERF for pl in players}
|
||||
else:
|
||||
ghost = sorted((rng.gauss(0, SIGMA_PERF) for _ in players), reverse=True)
|
||||
z = {pl: (ghost[0] - ghost[i]) / SIGMA_PERF for i, pl in enumerate(order)}
|
||||
|
||||
winner = order[0]
|
||||
eliminated = {pl for pl in order[1:] if z[pl] > ELIM_Z and rng.random() < ELIM_P}
|
||||
survivors = [pl for pl in order if pl not in eliminated]
|
||||
rmax = max_rounds(n, nine_rounds)
|
||||
|
||||
if len(survivors) == 1:
|
||||
reason = "last_standing"
|
||||
gap = z[order[1]]
|
||||
else:
|
||||
gap = z[survivors[1]]
|
||||
if gap < 0.02:
|
||||
reason = "resources"
|
||||
elif gap < 0.08:
|
||||
reason = "plastic"
|
||||
elif gap < 0.25:
|
||||
reason = "worlds"
|
||||
else:
|
||||
reason = "objectives"
|
||||
rnd = int(_clamp(round(rmax + 0.3 - 1.4 * gap + rng.gauss(0, 0.9)), 3, rmax))
|
||||
|
||||
need = n
|
||||
if reason == "last_standing":
|
||||
o_win = rng.randint(max(0, need - 2), need - 1)
|
||||
elif rnd < rmax:
|
||||
o_win = need
|
||||
else:
|
||||
o_win = need if rng.random() < 0.5 else need - 1
|
||||
objectives = {winner: o_win}
|
||||
for pl in order[1:]:
|
||||
o = round(o_win * (1 - 0.45 * z[pl]) + rng.gauss(0, 0.5))
|
||||
objectives[pl] = int(_clamp(o, 0, max(0, o_win - 1)))
|
||||
|
||||
total = int(worlds_on_board(n))
|
||||
mean_z = statistics.fmean(z[pl] for pl in survivors)
|
||||
worlds = {}
|
||||
for pl in order:
|
||||
if pl in eliminated:
|
||||
worlds[pl] = 0
|
||||
continue
|
||||
w = round(fair_worlds(n) * (1 + 0.35 * (mean_z - z[pl])) + rng.gauss(0, 0.8))
|
||||
worlds[pl] = int(_clamp(w, 1, total))
|
||||
|
||||
if reason in ("worlds", "plastic", "resources"):
|
||||
ru = survivors[1]
|
||||
objectives[ru] = o_win
|
||||
if reason == "worlds":
|
||||
if worlds[winner] <= worlds[ru]:
|
||||
worlds[winner] = worlds[ru] + 1
|
||||
else:
|
||||
worlds[ru] = worlds[winner]
|
||||
|
||||
seats = []
|
||||
for i, pl in enumerate(survivors):
|
||||
seats.append(Seat(pl, i + 1, objectives[pl], worlds[pl]))
|
||||
last = len(survivors) + 1
|
||||
for pl in order:
|
||||
if pl in eliminated:
|
||||
seats.append(Seat(pl, last, objectives[pl], 0, eliminated=True))
|
||||
return Match(seats, reason, rnd, nine_rounds)
|
||||
|
||||
|
||||
def strip_details(m: Match) -> Match:
|
||||
"""Партия «из старой истории»: только места и тип победы."""
|
||||
return Match(
|
||||
[Seat(s.player, s.place, eliminated=s.eliminated) for s in m.seats], m.win_reason
|
||||
)
|
||||
|
||||
|
||||
CLUB_OFFSETS = (-150.0, 0.0, 150.0) # сценарий «клубы»: средняя сила трёх групп
|
||||
CLUB_SIGMA = 90.0 # разброс силы внутри клуба
|
||||
CLUB_MIXED_SHARE = 0.1 # доля партий, где встречаются игроки разных клубов
|
||||
|
||||
|
||||
LEARN_DEFICIT = 200.0 # сценарий «рост»: максимальное стартовое отставание новичка
|
||||
LEARN_GAMES = 15.0 # за столько партий отставание уменьшается в e раз
|
||||
|
||||
|
||||
def generate_season(
|
||||
seed: int, n_matches: int, informative: bool, clubs: bool = False, learning: bool = False
|
||||
) -> tuple[dict, list[Match]]:
|
||||
"""Сезон: 12 игроков сразу, ещё по двое на 1/3 и 2/3 сезона.
|
||||
|
||||
clubs=True — игроки разбиты на три группы разной силы и почти всегда играют
|
||||
внутри своей; общий рейтинг должен их правильно «сшить».
|
||||
learning=True — сила растёт с опытом: θ − deficit·exp(−партии/LEARN_GAMES).
|
||||
Возвращает силу на КОНЕЦ сезона — её и должен отражать рейтинг."""
|
||||
rng = random.Random(seed)
|
||||
skill = {}
|
||||
activity = {}
|
||||
joins = {}
|
||||
club = {}
|
||||
deficit = {}
|
||||
played = {}
|
||||
for i in range(18 if clubs else 16):
|
||||
pl = f"p{i:02d}"
|
||||
if clubs:
|
||||
club[pl] = i % 3
|
||||
skill[pl] = CLUB_OFFSETS[club[pl]] + rng.gauss(0, CLUB_SIGMA)
|
||||
joins[pl] = 0 if i < 15 else n_matches // 3
|
||||
else:
|
||||
skill[pl] = rng.gauss(0, SIGMA_SKILL)
|
||||
joins[pl] = 0 if i < 12 else (n_matches // 3 if i < 14 else 2 * n_matches // 3)
|
||||
activity[pl] = rng.uniform(0.5, 1.5)
|
||||
deficit[pl] = rng.uniform(0, LEARN_DEFICIT) if learning else 0.0
|
||||
played[pl] = 0
|
||||
|
||||
def current(pl: str) -> float:
|
||||
return skill[pl] - deficit[pl] * math.exp(-played[pl] / LEARN_GAMES)
|
||||
|
||||
nine_rounds = rng.random() < 0.5
|
||||
matches = []
|
||||
for t in range(n_matches):
|
||||
active = [pl for pl in skill if joins[pl] <= t]
|
||||
if clubs and rng.random() >= CLUB_MIXED_SHARE:
|
||||
c = rng.randrange(3)
|
||||
active = [pl for pl in active if club[pl] == c]
|
||||
n = min(_choice_weighted(rng, TABLE_SIZES), len(active))
|
||||
pool = active[:]
|
||||
chosen = []
|
||||
for _ in range(n):
|
||||
pick = _choice_weighted(rng, [(pl, activity[pl]) for pl in pool])
|
||||
pool.remove(pick)
|
||||
chosen.append(pick)
|
||||
now = {pl: current(pl) for pl in chosen}
|
||||
matches.append(generate_match(rng, now, chosen, nine_rounds, informative))
|
||||
for pl in chosen:
|
||||
played[pl] += 1
|
||||
return {pl: current(pl) for pl in skill}, matches
|
||||
|
||||
|
||||
# ═══ Метрики ══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _ranks(xs: list[float]) -> list[float]:
|
||||
order = sorted(range(len(xs)), key=lambda i: xs[i])
|
||||
ranks = [0.0] * len(xs)
|
||||
i = 0
|
||||
while i < len(order):
|
||||
j = i
|
||||
while j + 1 < len(order) and xs[order[j + 1]] == xs[order[i]]:
|
||||
j += 1
|
||||
for t in range(i, j + 1):
|
||||
ranks[order[t]] = (i + j) / 2 + 1
|
||||
i = j + 1
|
||||
return ranks
|
||||
|
||||
|
||||
def spearman(xs: list[float], ys: list[float]) -> float:
|
||||
if len(xs) < 3:
|
||||
return float("nan")
|
||||
rx, ry = _ranks(xs), _ranks(ys)
|
||||
return statistics.correlation(rx, ry)
|
||||
|
||||
|
||||
CHECKPOINTS = (5, 10, 20)
|
||||
|
||||
|
||||
def evaluate(system, skill: dict[str, float], matches: list[Match], feed=None) -> dict:
|
||||
"""Прогоняет сезон. feed(m) — какую версию партии видит система (по умолчанию полную)."""
|
||||
half = len(matches) // 2
|
||||
hits = pairs = 0.0
|
||||
brier = []
|
||||
abs_moves = []
|
||||
at_k: dict[int, dict[str, float]] = {k: {} for k in CHECKPOINTS}
|
||||
games: dict[str, int] = {}
|
||||
for t, m in enumerate(matches):
|
||||
seen = feed(m) if feed else m
|
||||
if t >= half:
|
||||
for a, b in combinations(m.seats, 2):
|
||||
if a.place == b.place:
|
||||
continue
|
||||
if a.place > b.place:
|
||||
a, b = b, a
|
||||
ra, rb = system.rating(a.player), system.rating(b.player)
|
||||
pairs += 1
|
||||
hits += 1.0 if ra > rb else 0.5 if ra == rb else 0.0
|
||||
if system.probabilistic:
|
||||
brier.append((1.0 - expected(ra, rb, system.p.d)) ** 2)
|
||||
delta = system.update(seen)
|
||||
for s in m.seats:
|
||||
games[s.player] = games.get(s.player, 0) + 1
|
||||
gp = games[s.player]
|
||||
if gp > 20:
|
||||
abs_moves.append(abs(delta[s.player]) * system.move_scale)
|
||||
if gp in at_k:
|
||||
at_k[gp][s.player] = system.rating(s.player)
|
||||
played = [pl for pl in skill if games.get(pl, 0) >= 10]
|
||||
out = {
|
||||
"acc": hits / pairs if pairs else float("nan"),
|
||||
"rho": spearman([system.rating(pl) for pl in played], [skill[pl] for pl in played]),
|
||||
"move": statistics.fmean(abs_moves) if abs_moves else float("nan"),
|
||||
}
|
||||
for k in CHECKPOINTS:
|
||||
pls = list(at_k[k])
|
||||
out[f"rho@{k}"] = spearman([at_k[k][pl] for pl in pls], [skill[pl] for pl in pls])
|
||||
if system.probabilistic:
|
||||
out["brier"] = statistics.fmean(brier)
|
||||
rs = [system.rating(pl) for pl in played]
|
||||
ts = [skill[pl] * SKILL_TO_RATING for pl in played]
|
||||
mr, mt = statistics.fmean(rs), statistics.fmean(ts)
|
||||
out["rmse"] = math.sqrt(statistics.fmean(((r - mr) - (t - mt)) ** 2 for r, t in zip(rs, ts)))
|
||||
everyone = [system.rating(pl) for pl in games]
|
||||
out["inflation"] = statistics.fmean(everyone) - system.p.r0
|
||||
out["slope"] = _slope(ts, rs)
|
||||
return out
|
||||
|
||||
|
||||
def oracle_accuracy(skill: dict[str, float], matches: list[Match]) -> float:
|
||||
half = len(matches) // 2
|
||||
hits = pairs = 0
|
||||
for m in matches[half:]:
|
||||
for a, b in combinations(m.seats, 2):
|
||||
if a.place == b.place:
|
||||
continue
|
||||
if a.place > b.place:
|
||||
a, b = b, a
|
||||
pairs += 1
|
||||
hits += skill[a.player] > skill[b.player]
|
||||
return hits / pairs
|
||||
|
||||
|
||||
def _slope(xs: list[float], ys: list[float]) -> float:
|
||||
"""Наклон регрессии рейтинга на истинную силу: 1 — масштаб честный, >1 — раздут."""
|
||||
mx, my = statistics.fmean(xs), statistics.fmean(ys)
|
||||
sxx = sum((x - mx) ** 2 for x in xs)
|
||||
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx if sxx else float("nan")
|
||||
|
||||
|
||||
def summarize(rows: list[dict]) -> dict[str, tuple[float, float]]:
|
||||
keys = rows[0].keys()
|
||||
res = {}
|
||||
for k in keys:
|
||||
vals = [r[k] for r in rows if not math.isnan(r[k])]
|
||||
mean = statistics.fmean(vals)
|
||||
se = statistics.stdev(vals) / math.sqrt(len(vals)) if len(vals) > 1 else 0.0
|
||||
res[k] = (mean, se)
|
||||
return res
|
||||
|
||||
|
||||
# ═══ Коэффициенты ═════════════════════════════════════════════════════════════
|
||||
|
||||
PLAIN = Params(k_max=96.0, k_min=16.0) # чистый Elo со своими лучшими K (перебор, этап 1)
|
||||
CLOSENESS = (
|
||||
("objectives", 1.0),
|
||||
("worlds", 0.85),
|
||||
("plastic", 0.7),
|
||||
("resources", 0.6),
|
||||
("last_standing", 1.0),
|
||||
)
|
||||
# Для анализа чувствительности: отклонения от 1 вдвое больше.
|
||||
CLOSENESS_STRONG = tuple((r, 1.0 - 2 * (1.0 - c)) for r, c in CLOSENESS)
|
||||
PROPOSED = Params(
|
||||
w_table=0.5,
|
||||
w_tempo=1.0,
|
||||
w_obj=0.5,
|
||||
w_worlds=0.5,
|
||||
closeness=CLOSENESS,
|
||||
skip_eliminated_pairs=True,
|
||||
)
|
||||
|
||||
|
||||
# ═══ Примеры из документа ═════════════════════════════════════════════════════
|
||||
|
||||
VETERAN = 40 # партий у «опытного» игрока: K = k_min
|
||||
|
||||
|
||||
def _vets(*names: str) -> dict[str, int]:
|
||||
return {n: VETERAN for n in names}
|
||||
|
||||
|
||||
def examples() -> list[tuple[str, str, dict, dict, Match]]:
|
||||
"""(ключ, заголовок, рейтинги, сыграно партий, партия) — в порядке документа."""
|
||||
duel = lambda first, second, **kw: Match([Seat(first, 1), Seat(second, 2)], **kw) # noqa: E731
|
||||
five = [Seat("A", 1), Seat("B", 2), Seat("C", 3), Seat("D", 4), Seat("E", 5)]
|
||||
six = [Seat(x, i + 1) for i, x in enumerate("ABCDEF")]
|
||||
return [
|
||||
("1a", "Дуэль 1600 против 1400: побеждает сильный",
|
||||
{"A": 1600, "B": 1400}, _vets("A", "B"), duel("A", "B", win_reason="objectives")),
|
||||
("1b", "Дуэль 1600 против 1400: побеждает слабый",
|
||||
{"A": 1600, "B": 1400}, _vets("A", "B"), duel("B", "A", win_reason="objectives")),
|
||||
("2a", "Быстрая победа: 3-й раунд",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||
Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=3)),
|
||||
("2b", "Медленная победа: 8-й раунд",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||
Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=8)),
|
||||
("3a", "Первое место в дуэли",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="objectives")),
|
||||
("3b", "Стол на 5: все места",
|
||||
dict.fromkeys("ABCDE", 1500), _vets(*"ABCDE"), Match(five, "objectives")),
|
||||
("4a", "Тип победы без деталей: по мирам",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="worlds")),
|
||||
("4b", "Тип победы без деталей: по пластику",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="plastic")),
|
||||
("4c", "Тип победы без деталей: по ресурсам",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="resources")),
|
||||
("4d", "Самая близкая полная партия: по мирам на 8-м раунде, 2:2 цели, 6:5 миров",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||
Match([Seat("A", 1, 2, 6), Seat("B", 2, 2, 5)], "worlds", round=8)),
|
||||
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
||||
("5", "Стол на 4: двое выбывших, раунд 7",
|
||||
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
||||
Match(
|
||||
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
||||
Seat("C", 3, 1, 0, eliminated=True), Seat("D", 3, 0, 0, eliminated=True)],
|
||||
"objectives", round=7,
|
||||
)),
|
||||
("6a", "Стол на 6, конец на 8-м раунде, хоумрул 9 раундов включён",
|
||||
dict.fromkeys("ABCDEF", 1500), _vets(*"ABCDEF"),
|
||||
Match(six, "objectives", round=8, nine_rounds=True)),
|
||||
("6b", "Стол на 6, конец на 8-м раунде, хоумрул выключен",
|
||||
dict.fromkeys("ABCDEF", 1500), _vets(*"ABCDEF"),
|
||||
Match(six, "objectives", round=8, nine_rounds=False)),
|
||||
("7", "Новичок (0 партий) побеждает ветерана, оба 1500",
|
||||
{"A": 1500, "B": 1500}, {"A": 0, "B": VETERAN}, duel("A", "B", win_reason="objectives")),
|
||||
]
|
||||
|
||||
|
||||
# Изменения рейтинга в примерах (округление до 0.01) — те же числа стоят в документе.
|
||||
EXPECTED: dict[str, dict[str, float]] = {
|
||||
"1a": {"A": 3.84, "B": -3.84},
|
||||
"1b": {"B": 12.16, "A": -12.16},
|
||||
"2a": {"A": 11.18, "B": -11.18},
|
||||
"2b": {"A": 5.46, "B": -5.46},
|
||||
"3a": {"A": 8.0, "B": -8.0},
|
||||
"3b": {"A": 11.0, "B": 5.5, "C": 0.0, "D": -5.5, "E": -11.0},
|
||||
"4a": {"A": 6.8, "B": -6.8},
|
||||
"4b": {"A": 5.6, "B": -5.6},
|
||||
"4c": {"A": 4.8, "B": -4.8},
|
||||
"4d": {"A": 3.4, "B": -3.4},
|
||||
"4e": {"A": 16.0, "B": -16.0},
|
||||
"5": {"A": 9.27, "B": 5.85, "C": -7.6, "D": -7.53},
|
||||
"6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0},
|
||||
"6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66},
|
||||
"7": {"A": 32.0, "B": -8.0},
|
||||
}
|
||||
|
||||
|
||||
def run_examples(p: Params = PROPOSED, verbose: bool = True) -> None:
|
||||
for key, title, ratings, games, m in examples():
|
||||
trace: list = []
|
||||
delta = rate_match(ratings, games, m, p, trace)
|
||||
got = {pl: round(v, 2) for pl, v in delta.items()}
|
||||
if verbose:
|
||||
n = len(m.seats)
|
||||
print(f"\n### Пример {key}. {title}\n")
|
||||
print(f"N={n}, G(N)={table_weight(n, p):.3f}, раунд={m.round}, R_max={max_rounds(n, m.nine_rounds)}, "
|
||||
f"тип={m.win_reason}, K: " + ", ".join(f"{pl}={k_factor(games[pl], p):.1f}" for pl in ratings))
|
||||
print("\n| пара | S | E | темп | цели | миры | сумма | близость | M |")
|
||||
print("|---|---|---|---|---|---|---|---|---|")
|
||||
for t in trace:
|
||||
tempo = f"{t['tempo']:.3f}" if "tempo" in t else "—"
|
||||
print(f"| {t['a']}–{t['b']} | {t['S']} | {t['E']:.3f} | {tempo} | {t['obj']:.3f} | "
|
||||
f"{t['worlds']:.3f} | {t['additive']:.3f} | {t['closeness']} | {t['M']:.3f} |")
|
||||
print("\nΔR: " + ", ".join(f"{pl} {v:+.3f}" for pl, v in got.items()))
|
||||
if EXPECTED:
|
||||
assert got == EXPECTED[key], f"пример {key}: {got} ≠ {EXPECTED[key]}"
|
||||
if EXPECTED and verbose:
|
||||
print("\nВсе примеры совпадают с документом.")
|
||||
|
||||
|
||||
def check_scale_invariance(seasons: int = 1) -> None:
|
||||
"""Шкала «50 / 40» (R = 10·score + 1000, D и K ÷10) и шкала 1500 дают одинаковые
|
||||
точность, Brier и ρ; изменения рейтинга различаются ровно в 10 раз (документ, 4.1)."""
|
||||
for cfg in SCENARIOS.values():
|
||||
for s in range(seasons):
|
||||
skill, matches = generate_season(
|
||||
cfg["seed"] + s, SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
|
||||
)
|
||||
for p in (PROPOSED, PLAIN):
|
||||
small = replace(p, r0=(p.r0 - 1000) / 10, d=p.d / 10, k_max=p.k_max / 10, k_min=p.k_min / 10)
|
||||
big, tiny = evaluate(Elo(p), skill, matches), evaluate(Elo(small), skill, matches)
|
||||
for key in ("acc", "brier", "rho", *(f"rho@{k}" for k in CHECKPOINTS)):
|
||||
assert math.isclose(big[key], tiny[key], abs_tol=1e-9), f"шкала: {key}"
|
||||
assert math.isclose(big["move"], 10 * tiny["move"], rel_tol=1e-9), "шкала: |ΔR|"
|
||||
print("Шкала 1500 и шкала 50 дают одинаковые точность, Brier и ρ.")
|
||||
|
||||
|
||||
# ═══ Сценарии запуска ═════════════════════════════════════════════════════════
|
||||
|
||||
SEASON_MATCHES = 300
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"сигнал": {"informative": True, "clubs": False, "learning": False, "seed": 10_000},
|
||||
"шум": {"informative": False, "clubs": False, "learning": False, "seed": 30_000},
|
||||
"клубы": {"informative": True, "clubs": True, "learning": False, "seed": 40_000},
|
||||
"рост": {"informative": True, "clubs": False, "learning": True, "seed": 50_000},
|
||||
}
|
||||
|
||||
|
||||
def compare(seasons: int, scenario: str, proposed: Params) -> None:
|
||||
cfg = SCENARIOS[scenario]
|
||||
print(f"\n## Сравнение систем — сценарий «{scenario}», {seasons} сезонов по {SEASON_MATCHES} партий\n")
|
||||
variants = [
|
||||
("League Points (сейчас)", lambda: LeaguePoints(), None),
|
||||
("Elo, чистый", lambda: Elo(PLAIN), None),
|
||||
("Предложенная", lambda: Elo(proposed), None),
|
||||
("Предложенная, без новых полей", lambda: Elo(proposed), strip_details),
|
||||
]
|
||||
results = {name: [] for name, _, _ in variants}
|
||||
oracle = []
|
||||
for s in range(seasons):
|
||||
skill, matches = generate_season(
|
||||
cfg["seed"] + s, SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
|
||||
)
|
||||
oracle.append(oracle_accuracy(skill, matches))
|
||||
for name, make, feed in variants:
|
||||
results[name].append(evaluate(make(), skill, matches, feed))
|
||||
print(f"Потолок точности (прогноз по истинной силе): {statistics.fmean(oracle):.4f}\n")
|
||||
cols = ["acc", "brier", "rho", "rho@5", "rho@10", "rho@20", "rmse", "slope", "move", "inflation"]
|
||||
print("| Система | " + " | ".join(cols) + " |")
|
||||
print("|---" * (len(cols) + 1) + "|")
|
||||
for name, _, _ in variants:
|
||||
sm = summarize(results[name])
|
||||
cells = []
|
||||
for c in cols:
|
||||
if c not in sm:
|
||||
cells.append("—")
|
||||
else:
|
||||
mean, se = sm[c]
|
||||
cells.append(f"{mean:.4f} ±{se:.4f}" if c in ("acc", "brier") else f"{mean:.3f}")
|
||||
print(f"| {name} | " + " | ".join(cells) + " |")
|
||||
base = results["Elo, чистый"]
|
||||
prop = results["Предложенная"]
|
||||
lp = results["League Points (сейчас)"]
|
||||
d_brier = [p["brier"] - b["brier"] for p, b in zip(prop, base)]
|
||||
d_acc_lp = [p["acc"] - b["acc"] for p, b in zip(prop, lp)]
|
||||
d_acc = [p["acc"] - b["acc"] for p, b in zip(prop, base)]
|
||||
for title, ds in (
|
||||
("Brier: предложенная − чистый Elo", d_brier),
|
||||
("Точность: предложенная − чистый Elo", d_acc),
|
||||
("Точность: предложенная − League Points", d_acc_lp),
|
||||
):
|
||||
mean = statistics.fmean(ds)
|
||||
se = statistics.stdev(ds) / math.sqrt(len(ds))
|
||||
print(f"- {title}: {mean:+.4f} ± {se:.4f} (парная разница)")
|
||||
|
||||
|
||||
GRID_SEED_SHIFT = 100_000 # перебор идёт на других сезонах, чем итоговое сравнение
|
||||
|
||||
|
||||
def grid(seasons: int) -> None:
|
||||
"""Подбор K и весов по Brier (меньше — лучше) на отдельных от сравнения сезонах."""
|
||||
data: dict[str, list] = {}
|
||||
for name, cfg in SCENARIOS.items():
|
||||
data[name] = [
|
||||
generate_season(
|
||||
cfg["seed"] + GRID_SEED_SHIFT + s, SEASON_MATCHES,
|
||||
cfg["informative"], cfg["clubs"], cfg["learning"],
|
||||
)
|
||||
for s in range(seasons)
|
||||
]
|
||||
signal = [n for n, c in SCENARIOS.items() if c["informative"]]
|
||||
cache: dict = {}
|
||||
|
||||
def run(p: Params, scenario: str) -> tuple[float, float]:
|
||||
if (p, scenario) not in cache:
|
||||
rows = [evaluate(Elo(p), sk, ms) for sk, ms in data[scenario]]
|
||||
cache[(p, scenario)] = (
|
||||
statistics.fmean(r["brier"] for r in rows),
|
||||
statistics.fmean(r["rho@10"] for r in rows),
|
||||
)
|
||||
return cache[(p, scenario)]
|
||||
|
||||
def brier(p: Params, scenarios) -> float:
|
||||
return statistics.fmean(run(p, sc)[0] for sc in scenarios)
|
||||
|
||||
def rho10(p: Params, scenarios) -> float:
|
||||
return statistics.fmean(run(p, sc)[1] for sc in scenarios)
|
||||
|
||||
print(f"\n## Перебор: {seasons} сезонов на сценарий, критерий — средний Brier\n")
|
||||
print("### Этап 1. K чистого Elo (все сценарии)\n")
|
||||
print("| k_max | k_min | k_games | Brier |")
|
||||
print("|---|---|---|---|")
|
||||
k_res = []
|
||||
for k_games in (10, 20):
|
||||
for k_max in (48.0, 64.0, 96.0, 128.0, 160.0):
|
||||
for k_min in (16.0, 24.0, 32.0):
|
||||
p = replace(PLAIN, k_max=k_max, k_min=k_min, k_games=k_games)
|
||||
b = brier(p, SCENARIOS)
|
||||
k_res.append((b, k_max, k_min, k_games))
|
||||
print(f"| {k_max} | {k_min} | {k_games} | {b:.5f} |")
|
||||
_, k_max, k_min, k_games = min(k_res)
|
||||
print(f"\nЛучшие K: k_max={k_max}, k_min={k_min}, k_games={k_games}")
|
||||
|
||||
print(f"\n### Этап 2. Веса отрыва (K этапа 1; сценарии {', '.join(signal)}; «шум» — контроль)\n")
|
||||
res = []
|
||||
for w_table in (0.0, 0.25, 0.5):
|
||||
for w_tempo in (0.0, 0.5, 1.0):
|
||||
for w_obj in (0.0, 0.5, 1.0):
|
||||
for w_worlds in (0.0, 0.5, 1.0):
|
||||
for close in ((), CLOSENESS):
|
||||
p = Params(
|
||||
k_max=k_max, k_min=k_min, k_games=k_games, w_table=w_table,
|
||||
w_tempo=w_tempo, w_obj=w_obj, w_worlds=w_worlds, closeness=close,
|
||||
)
|
||||
res.append((brier(p, signal), p))
|
||||
res.sort(key=lambda x: x[0])
|
||||
zero = next(b for b, p in res if (p.w_table, p.w_tempo, p.w_obj, p.w_worlds) == (0, 0, 0, 0) and not p.closeness)
|
||||
plain_best = replace(PLAIN, k_max=k_max, k_min=k_min, k_games=k_games)
|
||||
print(f"Без множителя (все веса 0, без близости): Brier {zero:.5f}, "
|
||||
f"«шум» {brier(plain_best, ['шум']):.5f}\n")
|
||||
print("| Brier | Brier «шум» | w_table | w_tempo | w_obj | w_worlds | близость |")
|
||||
print("|---|---|---|---|---|---|---|")
|
||||
for b, p in res[:12]:
|
||||
noise = brier(p, ["шум"])
|
||||
print(
|
||||
f"| {b:.5f} | {noise:.5f} | {p.w_table} | {p.w_tempo} | {p.w_obj} | {p.w_worlds} | "
|
||||
f"{'да' if p.closeness else 'нет'} |"
|
||||
)
|
||||
print(f"\nХудшая комбинация: Brier {res[-1][0]:.5f}")
|
||||
|
||||
print("\n### Этап 3. Доводка K и поправка на автокорреляцию для предложенных весов\n")
|
||||
print("| k_max | k_min | k_games | autocorr | Brier (сигнальные) | Brier «шум» |")
|
||||
print("|---|---|---|---|---|---|")
|
||||
for k_games2 in (10, 20):
|
||||
for k_max2 in (48.0, 64.0, 80.0):
|
||||
for k_min2 in (12.0, 16.0, 24.0):
|
||||
for ac in (False, True):
|
||||
p = replace(PROPOSED, k_max=k_max2, k_min=k_min2, k_games=k_games2, autocorr=ac)
|
||||
print(
|
||||
f"| {k_max2} | {k_min2} | {k_games2} | {'да' if ac else 'нет'} | "
|
||||
f"{brier(p, signal):.5f} | {brier(p, ['шум']):.5f} |"
|
||||
)
|
||||
|
||||
print("\n### Этап 4. Чувствительность: один вес меняется, остальные — как в PROPOSED\n")
|
||||
print(f"PROPOSED: Brier {brier(PROPOSED, signal):.5f}, ρ@10 {rho10(PROPOSED, signal):.3f}, "
|
||||
f"Brier «шум» {brier(PROPOSED, ['шум']):.5f}\n")
|
||||
print("| параметр | значение | Brier (сигнальные) | ρ@10 (сигнальные) | Brier «шум» |")
|
||||
print("|---|---|---|---|---|")
|
||||
sweeps = [
|
||||
("w_table", (0.0, 0.25, 0.5, 1.0)),
|
||||
("w_tempo", (0.0, 0.25, 0.5, 1.0, 2.0)),
|
||||
("w_obj", (0.0, 0.25, 0.5, 1.0, 2.0)),
|
||||
("w_worlds", (0.0, 0.25, 0.5, 1.0, 2.0)),
|
||||
]
|
||||
for attr, values in sweeps:
|
||||
for v in values:
|
||||
p = replace(PROPOSED, **{attr: v})
|
||||
print(f"| {attr} | {v} | {brier(p, signal):.5f} | {rho10(p, signal):.3f} | {brier(p, ['шум']):.5f} |")
|
||||
for label, close in (("без близости", ()), ("близость ×2 сильнее", CLOSENESS_STRONG), ("предложенная", CLOSENESS)):
|
||||
p = replace(PROPOSED, closeness=close)
|
||||
print(f"| closeness | {label} | {brier(p, signal):.5f} | {rho10(p, signal):.3f} | {brier(p, ['шум']):.5f} |")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--seasons", type=int, default=200, help="сезонов в сравнении систем")
|
||||
ap.add_argument("--grid", action="store_true", help="перебор K и весов")
|
||||
ap.add_argument("--grid-seasons", type=int, default=40)
|
||||
args = ap.parse_args()
|
||||
print("# Примеры расчётов")
|
||||
run_examples()
|
||||
check_scale_invariance()
|
||||
if args.grid:
|
||||
grid(args.grid_seasons)
|
||||
return
|
||||
for scenario in SCENARIOS:
|
||||
compare(args.seasons, scenario, PROPOSED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#0f1115" />
|
||||
<title>Forbidden Stars — учёт партий</title>
|
||||
|
||||
+19
-1
@@ -1,15 +1,33 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/router";
|
||||
import { Spinner } from "./components/Spinner";
|
||||
import { ToastProvider } from "./context/ToastContext";
|
||||
import { setAppTzOffsetHours } from "./domain/format";
|
||||
import { useAuthConfig } from "./hooks/auth";
|
||||
|
||||
/**
|
||||
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
|
||||
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
|
||||
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
|
||||
*/
|
||||
function AppTimeZone({ children }: { children: ReactNode }) {
|
||||
const { data, isPending } = useAuthConfig();
|
||||
if (data) setAppTzOffsetHours(data.tz_offset_hours);
|
||||
if (isPending) return <Spinner />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
<AppTimeZone>
|
||||
<RouterProvider router={router} />
|
||||
</AppTimeZone>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "./schema";
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
export function readCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
@@ -53,3 +53,13 @@ export function unwrap<T>(res: FetchResult<T>): T {
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторы для проб сессии (`/users/me`, `/admin/me`). «Не авторизован» — это
|
||||
* только ответ сервера (401/403, обрабатывается в самих хуках); обрыв связи
|
||||
* ответом не является, и без повторов гварды приняли бы его за разлогин и
|
||||
* увели на страницу входа.
|
||||
*/
|
||||
export const authProbeRetry = (count: number, err: unknown): boolean =>
|
||||
!(err instanceof ApiError) && count < 2;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const qk = {
|
||||
me: ["me"] as const,
|
||||
adminMe: ["adminMe"] as const,
|
||||
@@ -7,6 +9,8 @@ export const qk = {
|
||||
factions: ["factions"] as const,
|
||||
myStats: ["myStats"] as const,
|
||||
publicProfile: (id: number) => ["publicProfile", id] as const,
|
||||
userMatches: (id: number) => ["userMatches", id] as const,
|
||||
userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
|
||||
groups: ["groups"] as const,
|
||||
group: (id: number) => ["group", id] as const,
|
||||
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
||||
@@ -17,10 +21,39 @@ export const qk = {
|
||||
authConfig: ["authConfig"] as const,
|
||||
invitations: ["invitations"] as const,
|
||||
notifications: ["notifications"] as const,
|
||||
announcements: ["announcements"] as const,
|
||||
devUsers: ["devUsers"] as const,
|
||||
adminUsers: ["adminUsers"] as const,
|
||||
adminGroups: ["adminGroups"] as const,
|
||||
adminMatches: ["adminMatches"] as const,
|
||||
adminLogs: ["adminLogs"] as const,
|
||||
adminAchievements: ["adminAchievements"] as const,
|
||||
adminAnnouncements: ["adminAnnouncements"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
|
||||
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
|
||||
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
|
||||
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
|
||||
* написан строкой.
|
||||
*/
|
||||
export const matchAffectedKeys = [
|
||||
qk.home,
|
||||
qk.leaderboard,
|
||||
qk.myStats,
|
||||
["userMatches"],
|
||||
["publicProfile"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Все рейтинговые витрины, включая статистику любой группы: рейтинг игроков в ней
|
||||
* общий, так что его двигает и партия другой группы (#88). Перезапрашиваются только
|
||||
* открытые на экране запросы, остальные лишь помечаются устаревшими.
|
||||
*/
|
||||
export function invalidateRatingViews(qc: QueryClient) {
|
||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||
qc.invalidateQueries({
|
||||
predicate: (q) => q.queryKey[0] === "group" && q.queryKey[2] === "stats",
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+1059
-15
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { AccountPage } from "../pages/AccountPage";
|
||||
import { CreateMatchPage } from "../pages/CreateMatchPage";
|
||||
import { GroupPage } from "../pages/GroupPage";
|
||||
import { GroupSettingsPage } from "../pages/GroupSettingsPage";
|
||||
import { HelpPage } from "../pages/HelpPage";
|
||||
import { HomePage } from "../pages/HomePage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
||||
@@ -13,6 +14,7 @@ import { OverallStatsPage } from "../pages/OverallStatsPage";
|
||||
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
|
||||
import { AdminAnnouncementsPage } from "../pages/admin/AdminAnnouncementsPage";
|
||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
||||
@@ -52,6 +54,7 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
},
|
||||
{ path: "account", element: <AccountPage /> },
|
||||
{ path: "help", element: <HelpPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -68,6 +71,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "factions", element: <AdminFactionsPage /> },
|
||||
{ path: "achievements", element: <AdminAchievementsPage /> },
|
||||
{ path: "logs", element: <AdminLogsPage /> },
|
||||
{ path: "announcements", element: <AdminAnnouncementsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
|
||||
@@ -5,25 +5,33 @@ import { Spinner } from "../components/Spinner";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useAdminMe } from "../hooks/admin";
|
||||
|
||||
/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */
|
||||
function OfflineNotice() {
|
||||
return <div className="muted">Нет связи с сервером. Проверьте подключение и обновите страницу.</div>;
|
||||
}
|
||||
|
||||
export function RequireAuth({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const { data: me, isLoading, isError } = useMe();
|
||||
const location = useLocation();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <OfflineNotice />;
|
||||
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireGroup({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const { data: me, isLoading, isError } = useMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <OfflineNotice />;
|
||||
if (!me) return <Navigate to="/login" replace />;
|
||||
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireAdmin({ children }: PropsWithChildren) {
|
||||
const { data: admin, isLoading } = useAdminMe();
|
||||
const { data: admin, isLoading, isError } = useAdminMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <OfflineNotice />;
|
||||
if (!admin) return <Navigate to="/admin/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user