Compare commits
35
Commits
171aebb6f8
...
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 |
+14
-5
@@ -1,13 +1,23 @@
|
|||||||
|
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
|
||||||
|
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
|
**/*.egg-info/
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
backend/.pytest_cache/
|
backend/.pytest_cache/
|
||||||
backend/*.db
|
|
||||||
backend/*.db-wal
|
|
||||||
backend/*.db-shm
|
|
||||||
backend/openapi.json
|
backend/openapi.json
|
||||||
|
|
||||||
|
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
|
||||||
|
# локальные копии бэкапов — ни в образ, ни в контекст сборки
|
||||||
|
backend/data/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
**/*.db
|
||||||
|
**/*.db-wal
|
||||||
|
**/*.db-shm
|
||||||
|
|
||||||
# Node / сборка фронта
|
# Node / сборка фронта
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
@@ -23,8 +33,7 @@ backend/tests/
|
|||||||
backend/pyproject.toml
|
backend/pyproject.toml
|
||||||
|
|
||||||
# Прочее
|
# Прочее
|
||||||
.env
|
**/.env
|
||||||
data/
|
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
*.md
|
*.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
+18
-6
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
|
||||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
@@ -14,6 +14,9 @@ APP_ENV=development
|
|||||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||||
|
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
|
||||||
|
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
|
||||||
|
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
|
||||||
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
||||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
||||||
LOCAL_PUBLIC=local
|
LOCAL_PUBLIC=local
|
||||||
@@ -41,8 +44,8 @@ ADMIN_NICKNAME=Администратор
|
|||||||
ADMIN_BOOTSTRAP_ENABLED=true
|
ADMIN_BOOTSTRAP_ENABLED=true
|
||||||
|
|
||||||
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
||||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
|
||||||
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||||
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
||||||
# и forbidden-stars.ru (dev).
|
# и forbidden-stars.ru (dev).
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
@@ -54,6 +57,7 @@ PUBLIC_BASE_URL=
|
|||||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||||
|
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
|
||||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_USER_TTL_MINUTES=10080
|
JWT_USER_TTL_MINUTES=10080
|
||||||
@@ -77,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
|
|||||||
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
||||||
|
|
||||||
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
||||||
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр,
|
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
|
||||||
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
||||||
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
||||||
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
||||||
@@ -85,7 +89,8 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
||||||
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
||||||
# Подними, если у Pi больше RAM/ядер.
|
# Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён
|
||||||
|
# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1.
|
||||||
#APP_MEM_LIMIT=512m
|
#APP_MEM_LIMIT=512m
|
||||||
#APP_CPUS=1.5
|
#APP_CPUS=1.5
|
||||||
|
|
||||||
@@ -119,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic
|
|||||||
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
||||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
||||||
BACKUP_SSH_KEY_B64=
|
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 и где там лежит
|
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
||||||
# docker-compose.yml прода.
|
# docker-compose.yml прода.
|
||||||
BACKUP_PI_SSH=pi@192.168.1.10
|
BACKUP_PI_SSH=pi@192.168.1.10
|
||||||
BACKUP_PI_DIR=~/forbidden-stars
|
BACKUP_PI_DIR=~/forbidden-stars
|
||||||
|
|
||||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
|
||||||
|
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
|
||||||
|
# пояс устройства игрока не учитывается.
|
||||||
APP_TZ_OFFSET_HOURS=3
|
APP_TZ_OFFSET_HOURS=3
|
||||||
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|||||||
@@ -3,16 +3,3 @@
|
|||||||
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
backend/entrypoint.sh text eol=lf
|
backend/entrypoint.sh text eol=lf
|
||||||
|
|
||||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ─────────
|
|
||||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
|
||||||
# но в архив деплоя (scripts/export-prod.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
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ mockups/
|
|||||||
|
|
||||||
# AI-ассистенты (локальные, в репозиторий не идут)
|
# AI-ассистенты (локальные, в репозиторий не идут)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Редактор / ОС
|
# Редактор / ОС
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
||||||
frontend/ React + Vite SPA
|
frontend/ React + Vite SPA
|
||||||
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
||||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh
|
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
|
||||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
docker-compose.yml прод на Pi: app + tunnel + backup
|
||||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
||||||
@@ -125,8 +125,10 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
||||||
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
||||||
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
||||||
дефолтным `ADMIN_PASSWORD` приложение не стартует. Учтите: `ADMIN_PASSWORD` применяется только
|
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
|
||||||
при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73).
|
при первом создании админа; сменить его потом — правкой `.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).
|
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||||
|
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
@@ -200,9 +202,8 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
||||||
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
||||||
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
||||||
исключены из образа (`.dockerignore`).
|
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
|
||||||
- **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило
|
и локальные бэкапы (`backups/`) в образ тоже не попадают.
|
||||||
`data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71).
|
|
||||||
|
|
||||||
## Git и деплой
|
## Git и деплой
|
||||||
|
|
||||||
@@ -213,10 +214,6 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
||||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
||||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
||||||
- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится):
|
|
||||||
`scripts/export-prod.sh <dir> [ref]` — через `git archive` + `export-ignore` из
|
|
||||||
`.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера).
|
|
||||||
`dev_admin.py` в `export-ignore` пока не внесён (задача #70).
|
|
||||||
|
|
||||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||||
|
|
||||||
|
|||||||
@@ -32,12 +32,15 @@ NEW_REASONS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
||||||
|
# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта
|
||||||
|
# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была.
|
||||||
BACKFILL_LAST_STANDING = """
|
BACKFILL_LAST_STANDING = """
|
||||||
UPDATE matches SET win_reason = 'last_standing'
|
UPDATE matches SET win_reason = 'last_standing'
|
||||||
WHERE status = 'finished'
|
WHERE status = 'finished'
|
||||||
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
||||||
AND (SELECT COUNT(*) FROM match_participants mp
|
AND (SELECT COUNT(*) FROM match_participants mp
|
||||||
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
|
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
||||||
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
||||||
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
|
||||||
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
|
||||||
|
включая IP.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||||
|
|
||||||
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
|
||||||
и dev-входом.
|
Telegram, /auth/login, /auth/register и dev-вход.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||||
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
Доступен во всех окружениях — наряду со входом по логину и паролю.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||||
|
|
||||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||||
|
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -11,6 +14,7 @@ from app.core.security import hash_password, verify_password
|
|||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.seed.reference_data import seed_reference_data
|
from app.seed.reference_data import seed_reference_data
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
|
||||||
def _ensure_admin(session: Session) -> None:
|
def _ensure_admin(session: Session) -> None:
|
||||||
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
|
|
||||||
# Администратор уже существует.
|
# Администратор уже существует.
|
||||||
if not settings.is_development:
|
if not settings.is_development:
|
||||||
# В prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
|
||||||
|
# по себе ничего не делает — применить её можно только осознанно, командой
|
||||||
|
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
|
||||||
return
|
return
|
||||||
|
|
||||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||||
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_admin_password(session: Session) -> User:
|
||||||
|
"""Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении.
|
||||||
|
|
||||||
|
Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт:
|
||||||
|
это единственный штатный способ, и он требует доступа к серверу. set_password
|
||||||
|
проверяет пароль и увеличивает token_version — все выданные админские сессии
|
||||||
|
(в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем."""
|
||||||
|
if not settings.admin_bootstrap_enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена."
|
||||||
|
)
|
||||||
|
password = (settings.admin_password or "").strip()
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.")
|
||||||
|
admin = session.exec(select(User).where(User.role == "admin")).first()
|
||||||
|
if admin is None:
|
||||||
|
raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.")
|
||||||
|
return user_service.set_password(session, admin, password)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> None:
|
def bootstrap() -> None:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
seed_reference_data(session) # идемпотентно
|
seed_reference_data(session) # идемпотентно
|
||||||
_ensure_admin(session)
|
_ensure_admin(session)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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()
|
bootstrap()
|
||||||
|
return
|
||||||
|
with Session(engine) as session:
|
||||||
|
admin = reset_admin_password(session)
|
||||||
|
print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; "
|
||||||
|
"все его сессии завершены.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
+42
-19
@@ -12,8 +12,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||||
|
|
||||||
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
|
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
|
||||||
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
|
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
|
||||||
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
||||||
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||||
_MIN_SECRET_KEY_LENGTH = 32
|
_MIN_SECRET_KEY_LENGTH = 32
|
||||||
@@ -31,18 +31,20 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Главный переключатель окружения: development | production ─────────────
|
# ── Главный переключатель окружения: development | production ─────────────
|
||||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
|
||||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
# Публикация локального dev-окружения наружу через VPS-туннель.
|
# Публикация локального dev-окружения наружу через VPS-туннель.
|
||||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
# Читает ЛАУНЧЕР (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"
|
local_public: str = "local"
|
||||||
|
|
||||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
|
||||||
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
|
||||||
|
# на фронте — оно приходит туда в GET /api/auth/config (#68).
|
||||||
app_tz_offset_hours: int = 3
|
app_tz_offset_hours: int = 3
|
||||||
|
|
||||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||||
@@ -66,8 +68,8 @@ class Settings(BaseSettings):
|
|||||||
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
||||||
cookie_domain: str | None = None
|
cookie_domain: str | None = None
|
||||||
|
|
||||||
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
|
||||||
# prod: только telegram) — отдельного переключателя провайдера нет.
|
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
|
||||||
telegram_bot_token: str | None = None
|
telegram_bot_token: str | None = None
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
public_base_url: str | None = None
|
public_base_url: str | None = None
|
||||||
@@ -111,11 +113,18 @@ class Settings(BaseSettings):
|
|||||||
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_secure(self) -> bool:
|
def is_published(self) -> bool:
|
||||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
"""Приложение доступно снаружи по домену: production или dev, выставленный через
|
||||||
нативный dev на localhost по HTTP (development + local_public=local)."""
|
VPS-туннель. Не опубликован только нативный dev на localhost
|
||||||
|
(development + local_public=local)."""
|
||||||
return not (self.is_development and self.local_public.lower() == "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
|
@property
|
||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
@@ -131,14 +140,23 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@field_validator("app_tz_offset_hours")
|
||||||
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
@classmethod
|
||||||
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
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
|
||||||
|
|
||||||
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
@model_validator(mode="after")
|
||||||
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
def _forbid_default_secrets_when_published(self) -> "Settings":
|
||||||
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
|
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
|
||||||
if self.app_env.lower() != "production":
|
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
|
||||||
|
|
||||||
|
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
|
||||||
|
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
|
||||||
|
if not self.is_published:
|
||||||
return self
|
return self
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
||||||
@@ -150,8 +168,13 @@ class Settings(BaseSettings):
|
|||||||
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
||||||
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
||||||
if problems:
|
if problems:
|
||||||
|
where = (
|
||||||
|
"production"
|
||||||
|
if self.is_production
|
||||||
|
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
|
||||||
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Небезопасная конфигурация production — задайте секреты в .env: "
|
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
|
||||||
+ "; ".join(problems)
|
+ "; ".join(problems)
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
||||||
перезапуска, но окно короткое, а рестарты редки;
|
перезапуска, но окно короткое, а рестарты редки;
|
||||||
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
||||||
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
|
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
|
||||||
про SSE-шину), общий для всех воркеров.
|
про SSE-шину), общий для всех воркеров.
|
||||||
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
||||||
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
||||||
|
|||||||
@@ -175,6 +175,6 @@ def is_session_revoked(payload: dict) -> bool:
|
|||||||
def client_ip(request: Request) -> str | None:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""IP клиента для журнала аудита.
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
|
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
|
||||||
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
|
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
|
||||||
return request.client.host if request.client else None
|
return request.client.host if request.client else None
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
|
||||||
|
|
||||||
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
|
|
||||||
где БД поднимается из чистого состояния.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from app.db.session import engine
|
|
||||||
from app.seed.reference_data import seed_reference_data
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_and_seed() -> None:
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
with Session(engine) as session:
|
|
||||||
seed_reference_data(session)
|
|
||||||
+13
-1
@@ -17,6 +17,7 @@ from app.core.errors import AppError, app_error_handler
|
|||||||
from app.routers import (
|
from app.routers import (
|
||||||
achievements,
|
achievements,
|
||||||
admin,
|
admin,
|
||||||
|
announcements,
|
||||||
auth,
|
auth,
|
||||||
events,
|
events,
|
||||||
groups,
|
groups,
|
||||||
@@ -131,6 +132,16 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
hub.bind_loop(asyncio.get_running_loop())
|
hub.bind_loop(asyncio.get_running_loop())
|
||||||
|
|
||||||
|
if settings.is_development and settings.is_published:
|
||||||
|
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
|
||||||
|
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
|
||||||
|
logging.getLogger("fs").warning(
|
||||||
|
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
|
||||||
|
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
|
||||||
|
"и Swagger. Не держите в dev-базе копию прод-данных.",
|
||||||
|
settings.local_public,
|
||||||
|
)
|
||||||
|
|
||||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||||
@@ -199,7 +210,8 @@ def create_app() -> FastAPI:
|
|||||||
# API-роутеры под /api.
|
# API-роутеры под /api.
|
||||||
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
||||||
matches.router, reference.router, stats.router, achievements.router,
|
matches.router, reference.router, stats.router, achievements.router,
|
||||||
events.router, notifications.router, admin.router]
|
events.router, notifications.router, announcements.router,
|
||||||
|
admin.router]
|
||||||
for r in api_routers:
|
for r in api_routers:
|
||||||
app.include_router(r, prefix="/api")
|
app.include_router(r, prefix="/api")
|
||||||
|
|
||||||
|
|||||||
@@ -447,6 +447,69 @@ class Notification(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Объявления администрации ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Announcement(SQLModel, table=True):
|
||||||
|
"""Объявление администрации (#84): окно поверх приложения в период показа, каждому
|
||||||
|
игроку — пока он его не закроет.
|
||||||
|
|
||||||
|
body_html — уже очищенный сервером HTML (announcement_service.sanitize_body), фронт
|
||||||
|
вставляет его как есть. revision растёт, когда админ сохраняет правку с «показать
|
||||||
|
заново»: закрывшие прежнюю версию увидят объявление ещё раз с пометкой «обновлено».
|
||||||
|
show_to_new_players=False — не показывать зарегистрировавшимся после starts_at."""
|
||||||
|
|
||||||
|
__tablename__ = "announcements"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
|
||||||
|
Index("ix_announcements_period", "starts_at", "ends_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
title: str = Field(sa_column=Column(String(64), nullable=False))
|
||||||
|
body_html: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
starts_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
|
||||||
|
ends_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
|
||||||
|
show_to_new_players: bool = Field(
|
||||||
|
default=True,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="1"),
|
||||||
|
)
|
||||||
|
revision: int = Field(default=1, sa_column=Column(Integer, nullable=False, server_default="1"))
|
||||||
|
created_by: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=utcnow,
|
||||||
|
sa_column_kwargs={"onupdate": utcnow},
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementView(SQLModel, table=True):
|
||||||
|
"""Игрок закрыл объявление. revision — какую версию он видел: если админ выпустил
|
||||||
|
новую, объявление покажется снова."""
|
||||||
|
|
||||||
|
__tablename__ = "announcement_views"
|
||||||
|
|
||||||
|
announcement_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
Integer, ForeignKey("announcements.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
user_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, index=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
revision: int = Field(sa_column=Column(Integer, nullable=False))
|
||||||
|
closed_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AuditLog(SQLModel, table=True):
|
class AuditLog(SQLModel, table=True):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.schemas import api as s
|
|||||||
from app.services import (
|
from app.services import (
|
||||||
achievement_service,
|
achievement_service,
|
||||||
admin_service,
|
admin_service,
|
||||||
|
announcement_service,
|
||||||
attachment_service,
|
attachment_service,
|
||||||
audit_service,
|
audit_service,
|
||||||
faction_service,
|
faction_service,
|
||||||
@@ -314,16 +315,15 @@ def delete_match(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.OkResponse:
|
) -> s.OkResponse:
|
||||||
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" # для уведомления
|
||||||
participant_ids = notify.match_participant_ids(session, match_id)
|
|
||||||
admin_service.delete_match(session, match_id)
|
admin_service.delete_match(session, match_id)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||||
ip=client_ip(request),
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id, group_id, participant_ids)
|
notify.match_removed(session, match_id, group_id, finished=finished)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@@ -463,6 +463,127 @@ def delete_achievement(
|
|||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Объявления ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _announcement_read(item: dict) -> s.AdminAnnouncementRead:
|
||||||
|
a = item["a"]
|
||||||
|
return s.AdminAnnouncementRead(
|
||||||
|
id=a.id,
|
||||||
|
title=a.title,
|
||||||
|
body_html=a.body_html,
|
||||||
|
starts_at=iso_utc(a.starts_at), # type: ignore[arg-type]
|
||||||
|
ends_at=iso_utc(a.ends_at), # type: ignore[arg-type]
|
||||||
|
show_to_new_players=a.show_to_new_players,
|
||||||
|
revision=a.revision,
|
||||||
|
status=item["status"],
|
||||||
|
seen_count=item["seen"],
|
||||||
|
audience_count=item["audience"],
|
||||||
|
created_at=iso_utc(a.created_at), # type: ignore[arg-type]
|
||||||
|
updated_at=iso_utc(a.updated_at), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _announcement_by_id(session: Session, announcement_id: int) -> s.AdminAnnouncementRead:
|
||||||
|
return _announcement_read(announcement_service.admin_item(session, announcement_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/announcements", response_model=list[s.AdminAnnouncementRead])
|
||||||
|
def list_announcements(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
) -> list[s.AdminAnnouncementRead]:
|
||||||
|
return [_announcement_read(i) for i in announcement_service.list_admin(session)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/announcements", response_model=s.AdminAnnouncementRead)
|
||||||
|
def create_announcement(
|
||||||
|
body: s.AnnouncementWrite,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.AdminAnnouncementRead:
|
||||||
|
a = announcement_service.create(
|
||||||
|
session,
|
||||||
|
title=body.title,
|
||||||
|
body_html=body.body_html,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
ends_at=body.ends_at,
|
||||||
|
show_to_new_players=body.show_to_new_players,
|
||||||
|
actor_id=admin.id,
|
||||||
|
)
|
||||||
|
audit_service.record(
|
||||||
|
session, actor_id=admin.id, action="create", entity_type="announcement",
|
||||||
|
entity_id=a.id, payload={"title": a.title}, ip=client_ip(request),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
notify.announcements_changed(session)
|
||||||
|
return _announcement_by_id(session, a.id) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/announcements/{announcement_id}", response_model=s.AdminAnnouncementRead)
|
||||||
|
def update_announcement(
|
||||||
|
announcement_id: int,
|
||||||
|
body: s.AnnouncementUpdate,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.AdminAnnouncementRead:
|
||||||
|
a = announcement_service.update(
|
||||||
|
session,
|
||||||
|
announcement_id,
|
||||||
|
title=body.title,
|
||||||
|
body_html=body.body_html,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
ends_at=body.ends_at,
|
||||||
|
show_to_new_players=body.show_to_new_players,
|
||||||
|
reshow=body.reshow,
|
||||||
|
)
|
||||||
|
audit_service.record(
|
||||||
|
session, actor_id=admin.id, action="update", entity_type="announcement",
|
||||||
|
entity_id=announcement_id,
|
||||||
|
payload={"title": a.title, "reshow": body.reshow, "revision": a.revision},
|
||||||
|
ip=client_ip(request),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
notify.announcements_changed(session)
|
||||||
|
return _announcement_by_id(session, announcement_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/announcements/{announcement_id}/stop", response_model=s.AdminAnnouncementRead)
|
||||||
|
def stop_announcement(
|
||||||
|
announcement_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.AdminAnnouncementRead:
|
||||||
|
"""«Снять с показа»: период идущего объявления заканчивается сейчас."""
|
||||||
|
announcement_service.stop(session, announcement_id)
|
||||||
|
audit_service.record(
|
||||||
|
session, actor_id=admin.id, action="update", entity_type="announcement",
|
||||||
|
entity_id=announcement_id, payload={"stopped": True}, ip=client_ip(request),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
notify.announcements_changed(session)
|
||||||
|
return _announcement_by_id(session, announcement_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/announcements/{announcement_id}", response_model=s.OkResponse)
|
||||||
|
def delete_announcement(
|
||||||
|
announcement_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.OkResponse:
|
||||||
|
announcement_service.delete(session, announcement_id)
|
||||||
|
audit_service.record(
|
||||||
|
session, actor_id=admin.id, action="delete", entity_type="announcement",
|
||||||
|
entity_id=announcement_id, ip=client_ip(request),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
notify.announcements_changed(session)
|
||||||
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/audit-logs", response_model=s.AuditLogList)
|
@router.get("/audit-logs", response_model=s.AuditLogList)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
|
|||||||
return s.AuthConfig(
|
return s.AuthConfig(
|
||||||
methods=enabled_methods(),
|
methods=enabled_methods(),
|
||||||
telegram_bot_username=settings.telegram_bot_username,
|
telegram_bot_username=settings.telegram_bot_username,
|
||||||
|
tz_offset_hours=settings.app_tz_offset_hours,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
||||||
|
|
||||||
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
||||||
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
|
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
|
||||||
входа по логину остаётся только на деве.
|
без пароля остаётся только на деве (по логину и паролю входят везде).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -352,9 +352,7 @@ def delete_match(
|
|||||||
match_service.assert_can_modify(session, match, user)
|
match_service.assert_can_modify(session, match, user)
|
||||||
match_id_val = match.id
|
match_id_val = match.id
|
||||||
group_id_val = match.group_id
|
group_id_val = match.group_id
|
||||||
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
|
finished = match.status == "finished" # после удаления статус уже не прочитать
|
||||||
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
|
|
||||||
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
|
|
||||||
match_service.delete_match(session, match, expected_version=expected_version)
|
match_service.delete_match(session, match, expected_version=expected_version)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
@@ -367,5 +365,5 @@ def delete_match(
|
|||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
|
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -22,6 +22,9 @@ class AuthConfig(BaseModel):
|
|||||||
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||||
methods: list[str] = []
|
methods: list[str] = []
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
|
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
|
||||||
|
# показывает время всем игрокам — независимо от пояса устройства (#68).
|
||||||
|
tz_offset_hours: int
|
||||||
|
|
||||||
|
|
||||||
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
||||||
@@ -233,6 +236,23 @@ class NotificationMarkRead(BaseModel):
|
|||||||
ids: list[int] | None = None
|
ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Объявления (игрок) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AnnouncementRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
# HTML, уже очищенный сервером по белому списку — фронт вставляет как есть.
|
||||||
|
body_html: str
|
||||||
|
revision: int
|
||||||
|
# Игрок закрывал прежнюю версию — окно показывает пометку «обновлено».
|
||||||
|
updated: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementAck(BaseModel):
|
||||||
|
# Версия, которую игрок видел и закрыл (AnnouncementRead.revision).
|
||||||
|
revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
# ─── Партии ──────────────────────────────────────────────────────────────────
|
# ─── Партии ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class RandomizeRequest(BaseModel):
|
class RandomizeRequest(BaseModel):
|
||||||
@@ -384,6 +404,9 @@ class LeaderboardEntry(OverallStats):
|
|||||||
nickname: str
|
nickname: str
|
||||||
rank: int | None = None
|
rank: int | None = None
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
|
||||||
|
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
|
||||||
|
rating_confirmed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MatchHistory(BaseModel):
|
class MatchHistory(BaseModel):
|
||||||
@@ -498,6 +521,9 @@ class MatchListItem(BaseModel):
|
|||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
created_by: int
|
created_by: int
|
||||||
participants: list[MatchListParticipant] = []
|
participants: list[MatchListParticipant] = []
|
||||||
|
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
|
||||||
|
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
|
||||||
|
rating_delta: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class MatchList(BaseModel):
|
class MatchList(BaseModel):
|
||||||
@@ -621,3 +647,39 @@ class AuditLogList(BaseModel):
|
|||||||
items: list[AuditLogItem] = []
|
items: list[AuditLogItem] = []
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Объявления (админ) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
AnnouncementStatus = Literal["planned", "live", "finished"]
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementWrite(BaseModel):
|
||||||
|
# Точные правила (заголовок до 60 символов, текст до 600 видимых) — в
|
||||||
|
# announcement_service; здесь только отсечка мегабайтных тел.
|
||||||
|
title: str = Field(max_length=200)
|
||||||
|
body_html: str = Field(max_length=20_000)
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime
|
||||||
|
show_to_new_players: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementUpdate(AnnouncementWrite):
|
||||||
|
# Показать заново тем, кто уже закрыл (с пометкой «обновлено»).
|
||||||
|
reshow: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAnnouncementRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
body_html: str
|
||||||
|
starts_at: str
|
||||||
|
ends_at: str
|
||||||
|
show_to_new_players: bool
|
||||||
|
revision: int
|
||||||
|
status: AnnouncementStatus
|
||||||
|
# Закрыли текущую версию / сколько активных игроков в адресатах.
|
||||||
|
seen_count: int
|
||||||
|
audience_count: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ FACTIONS: list[tuple[str, str, str, int]] = [
|
|||||||
|
|
||||||
|
|
||||||
def seed_reference_data(session: Session) -> None:
|
def seed_reference_data(session: Session) -> None:
|
||||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
|
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
|
||||||
|
|
||||||
|
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
|
||||||
|
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
|
||||||
|
и после создания записи источник правды для него — БД (#72). Имя из кода получает
|
||||||
|
только новая фракция; поправить имя существующей — админкой или миграцией.
|
||||||
|
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
|
||||||
code_to_expansion: dict[str, Expansion] = {}
|
code_to_expansion: dict[str, Expansion] = {}
|
||||||
|
|
||||||
for code, name_ru, is_base, order in EXPANSIONS:
|
for code, name_ru, is_base, order in EXPANSIONS:
|
||||||
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
|
|||||||
)
|
)
|
||||||
session.add(fac)
|
session.add(fac)
|
||||||
else:
|
else:
|
||||||
fac.name_ru = name_ru
|
|
||||||
fac.expansion_id = expansion.id # type: ignore[assignment]
|
fac.expansion_id = expansion.id # type: ignore[assignment]
|
||||||
fac.sort_order = order
|
fac.sort_order = order
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
|
|
||||||
|
|
||||||
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
||||||
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
|
"""Новый пароль игроку (восстановление забытого). Пароль админа через панель не
|
||||||
он задаётся ADMIN_PASSWORD в .env."""
|
меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
|
||||||
|
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
|
||||||
user = session.get(User, user_id)
|
user = session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("Пользователь не найден.")
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.events import hub
|
from app.core.events import hub
|
||||||
from app.models import GroupMember, Match, MatchParticipant
|
from app.models import GroupMember, Match, User
|
||||||
|
|
||||||
|
|
||||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||||
@@ -17,30 +17,28 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def match_participant_ids(session: Session, match_id: int) -> list[int]:
|
def _ratings_changed(session: Session, notified: list[int]) -> None:
|
||||||
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
|
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
|
||||||
(история игр, публичный профиль, личная статистика) реально протухли."""
|
главную, историю и профили всех, кто играл после неё, и страницы других групп.
|
||||||
return list(
|
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
|
||||||
session.exec(
|
skip = set(notified)
|
||||||
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
|
ids = [
|
||||||
|
uid
|
||||||
|
for uid in session.exec(
|
||||||
|
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
||||||
).all()
|
).all()
|
||||||
)
|
if uid not in skip
|
||||||
|
]
|
||||||
|
hub.publish(ids, {"type": "ratings"})
|
||||||
|
|
||||||
|
|
||||||
def match_changed(session: Session, match: Match) -> None:
|
def match_changed(session: Session, match: Match) -> None:
|
||||||
"""Партия изменилась — уведомить всех участников её группы.
|
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
|
||||||
|
и остальных игроков (_ratings_changed)."""
|
||||||
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
|
members = _group_member_ids(session, match.group_id)
|
||||||
история и профили протухают только у игравших, поэтому их id едут в событии."""
|
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
|
||||||
hub.publish(
|
if match.status == "finished":
|
||||||
_group_member_ids(session, match.group_id),
|
_ratings_changed(session, members)
|
||||||
{
|
|
||||||
"type": "match",
|
|
||||||
"match_id": match.id,
|
|
||||||
"group_id": match.group_id,
|
|
||||||
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||||
@@ -53,22 +51,13 @@ def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
|||||||
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
||||||
|
|
||||||
|
|
||||||
def match_removed(
|
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
|
||||||
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
|
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
|
||||||
) -> None:
|
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
|
||||||
"""Партия удалена — уведомить участников группы (обновить списки).
|
members = _group_member_ids(session, group_id)
|
||||||
|
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
|
||||||
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
|
if finished:
|
||||||
ушли каскадом, и собрать их из базы невозможно."""
|
_ratings_changed(session, members)
|
||||||
hub.publish(
|
|
||||||
_group_member_ids(session, group_id),
|
|
||||||
{
|
|
||||||
"type": "match",
|
|
||||||
"match_id": match_id,
|
|
||||||
"group_id": group_id,
|
|
||||||
"participant_ids": participant_ids or [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||||
@@ -87,3 +76,13 @@ def invitations_changed(user_id: int) -> None:
|
|||||||
def notifications_changed(user_id: int) -> None:
|
def notifications_changed(user_id: int) -> None:
|
||||||
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
|
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
|
||||||
hub.publish([user_id], {"type": "notifications"})
|
hub.publish([user_id], {"type": "notifications"})
|
||||||
|
|
||||||
|
|
||||||
|
def announcements_changed(session: Session) -> None:
|
||||||
|
"""Админ создал, поправил, снял или удалил объявление — всем активным игрокам:
|
||||||
|
открытые вкладки перезапросят, что показать. Объявление, чей период начнётся
|
||||||
|
позже, клиент подхватит сам — периодическим перезапросом."""
|
||||||
|
ids = session.exec(
|
||||||
|
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
||||||
|
).all()
|
||||||
|
hub.publish(ids, {"type": "announcements"})
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
||||||
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
||||||
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
||||||
K_i спускается от K_MAX у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола,
|
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
|
||||||
M — множитель отрыва (темп, цели, миры; близость по типу победы — только у пар
|
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
|
||||||
с победителем). Недостающий признак партии подставляется типичным и не влияет на M.
|
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
|
||||||
|
признак партии подставляется типичным и не влияет на M.
|
||||||
|
|
||||||
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
||||||
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
||||||
@@ -168,6 +169,10 @@ def rate_match(
|
|||||||
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
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}
|
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):
|
for a, b in combinations(m.seats, 2):
|
||||||
|
if a.eliminated and b.eliminated:
|
||||||
|
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
|
||||||
|
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
|
||||||
|
continue
|
||||||
if a.place > b.place:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
|
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
|
||||||
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
|
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
|
||||||
считаются игры, победы, среднее место и разбивки. Цепочек две: общая — по всем партиям
|
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
|
||||||
приложения, групповая — только по партиям группы (это разные числа)."""
|
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
|
||||||
|
винрейт и среднее место считает по партиям группы."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -25,25 +26,41 @@ from app.services.scoring import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_history(session: Session, group_id: int | None = None) -> list[RatedMatch]:
|
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
|
||||||
"""Завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
|
||||||
|
|
||||||
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет."""
|
|
||||||
|
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 load_history(session: Session) -> list[RatedMatch]:
|
||||||
|
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
||||||
|
|
||||||
|
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
|
||||||
|
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
|
||||||
|
_for_group по этой же истории: рейтинг считается только целиком."""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Match, MatchParticipant)
|
select(Match, MatchParticipant)
|
||||||
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||||
.where(Match.status == "finished")
|
.where(Match.status == "finished")
|
||||||
|
.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
|
||||||
)
|
)
|
||||||
if group_id is not None:
|
|
||||||
stmt = stmt.where(Match.group_id == group_id)
|
|
||||||
stmt = stmt.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
|
|
||||||
|
|
||||||
history: list[RatedMatch] = []
|
history: list[RatedMatch] = []
|
||||||
current: Match | None = None
|
current: Match | None = None
|
||||||
seats: list[RatedSeat] = []
|
seats: list[RatedSeat] = []
|
||||||
|
|
||||||
def flush() -> None:
|
def flush() -> None:
|
||||||
if current is not None and seats:
|
if current is not None and len(seats) >= MIN_PARTICIPANTS:
|
||||||
history.append(
|
history.append(
|
||||||
RatedMatch(
|
RatedMatch(
|
||||||
seats=tuple(seats),
|
seats=tuple(seats),
|
||||||
@@ -85,6 +102,11 @@ def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatc
|
|||||||
return [(m, s) for m in history for s in m.seats if s.user_id == user_id]
|
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:
|
def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict:
|
||||||
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
|
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
|
||||||
|
|
||||||
@@ -109,16 +131,25 @@ def leaderboard(
|
|||||||
*,
|
*,
|
||||||
history: list[RatedMatch] | None = None,
|
history: list[RatedMatch] | None = None,
|
||||||
rep: Replay | None = None,
|
rep: Replay | None = None,
|
||||||
|
member_ids: set[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует)."""
|
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и
|
||||||
|
group_stats их переиспользуют).
|
||||||
|
|
||||||
|
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
|
||||||
|
считается по всем партиям. С group_id игры, победы, винрейт и среднее место берутся
|
||||||
|
только из партий группы (#80). member_ids — показывать только этих игроков (топ
|
||||||
|
группы — её текущий состав, #76); места нумеруются уже после фильтра."""
|
||||||
if history is None:
|
if history is None:
|
||||||
history = load_history(session, group_id)
|
history = load_history(session)
|
||||||
if rep is None:
|
if rep is None:
|
||||||
rep = replay(history)
|
rep = replay(history)
|
||||||
|
shown = history if group_id is None else _for_group(history, group_id)
|
||||||
|
|
||||||
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
||||||
for m in history:
|
for m in shown:
|
||||||
for s in m.seats:
|
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))
|
by_user[s.user_id].append((m, s))
|
||||||
users = (
|
users = (
|
||||||
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
|
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
|
||||||
@@ -135,11 +166,12 @@ def leaderboard(
|
|||||||
"nickname": u.nickname,
|
"nickname": u.nickname,
|
||||||
**_summary(seats, rep.ratings[uid]),
|
**_summary(seats, rep.ratings[uid]),
|
||||||
"rating": 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]
|
"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["games"] >= MIN_GAMES), key=leaderboard_sort_key)
|
qualified = sorted((r for r in rows if r["rating_confirmed"]), key=leaderboard_sort_key)
|
||||||
provisional = sorted((r for r in rows if r["games"] < MIN_GAMES), key=leaderboard_sort_key)
|
provisional = sorted((r for r in rows if not r["rating_confirmed"]), key=leaderboard_sort_key)
|
||||||
for i, r in enumerate(qualified, start=1):
|
for i, r in enumerate(qualified, start=1):
|
||||||
r["rank"] = i
|
r["rank"] = i
|
||||||
for r in provisional:
|
for r in provisional:
|
||||||
@@ -227,17 +259,14 @@ def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
|
|||||||
def profile_stats(
|
def profile_stats(
|
||||||
session: Session,
|
session: Session,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
group_id: int | None = None,
|
|
||||||
*,
|
*,
|
||||||
history: list[RatedMatch] | None = None,
|
history: list[RatedMatch] | None = None,
|
||||||
rep: Replay | None = None,
|
rep: Replay | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Витрина профиля. history/rep — общая цепочка, уже посчитанная (home их переиспользует);
|
"""Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
|
||||||
с group_id считается групповая цепочка."""
|
переиспользует)."""
|
||||||
if history is None:
|
if history is None:
|
||||||
history = load_history(session)
|
history = load_history(session)
|
||||||
if group_id is not None:
|
|
||||||
history, rep = _for_group(history, group_id), None
|
|
||||||
if rep is None:
|
if rep is None:
|
||||||
rep = replay(history)
|
rep = replay(history)
|
||||||
seats = _user_seats(history, user_id)
|
seats = _user_seats(history, user_id)
|
||||||
@@ -262,13 +291,23 @@ def profile_stats(
|
|||||||
|
|
||||||
|
|
||||||
def group_stats(session: Session, group_id: int) -> dict:
|
def group_stats(session: Session, group_id: int) -> dict:
|
||||||
history = load_history(session, group_id)
|
history = load_history(session)
|
||||||
board = leaderboard(session, group_id, history=history, rep=replay(history))
|
rep = replay(history)
|
||||||
last_played = max((m.played_at for m in history), default=None)
|
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)
|
games: dict[int, int] = defaultdict(int)
|
||||||
wins: dict[int, int] = defaultdict(int)
|
wins: dict[int, int] = defaultdict(int)
|
||||||
for m in history:
|
for m in group_history:
|
||||||
for s in m.seats:
|
for s in m.seats:
|
||||||
games[s.faction_id] += 1
|
games[s.faction_id] += 1
|
||||||
wins[s.faction_id] += s.place == 1
|
wins[s.faction_id] += s.place == 1
|
||||||
@@ -288,14 +327,16 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
for f in factions
|
for f in factions
|
||||||
]
|
]
|
||||||
|
|
||||||
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
|
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
|
||||||
|
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
|
||||||
played_ids = {e["user_id"] for e in board["entries"]} | {
|
played_ids = {e["user_id"] for e in board["entries"]} | {
|
||||||
e["user_id"] for e in board["provisional"]
|
e["user_id"] for e in board["provisional"]
|
||||||
}
|
}
|
||||||
inactive = []
|
inactive = []
|
||||||
for _m, u in membership_service.list_members(session, group_id):
|
for _m, u in members:
|
||||||
if u.id in played_ids:
|
if u.id in played_ids:
|
||||||
continue
|
continue
|
||||||
|
rating = rep.ratings.get(u.id) # type: ignore[arg-type]
|
||||||
inactive.append(
|
inactive.append(
|
||||||
{
|
{
|
||||||
"user_id": u.id,
|
"user_id": u.id,
|
||||||
@@ -304,7 +345,8 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
"wins": 0,
|
"wins": 0,
|
||||||
"win_rate": 0.0,
|
"win_rate": 0.0,
|
||||||
"avg_place": None,
|
"avg_place": None,
|
||||||
"score": None,
|
"score": None if rating is None else round(rating),
|
||||||
|
"rating_confirmed": _rating_confirmed(rep, u.id), # type: ignore[arg-type]
|
||||||
"rank": None,
|
"rank": None,
|
||||||
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||||
}
|
}
|
||||||
@@ -312,7 +354,7 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"group_id": group_id,
|
"group_id": group_id,
|
||||||
"total_matches": len(history),
|
"total_matches": len(group_history),
|
||||||
"last_match_at": str(last_played) if last_played else None,
|
"last_match_at": str(last_played) if last_played else None,
|
||||||
"leaderboard": board["entries"],
|
"leaderboard": board["entries"],
|
||||||
"provisional": board["provisional"],
|
"provisional": board["provisional"],
|
||||||
@@ -355,8 +397,12 @@ def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int,
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _match_items(session: Session, matches) -> list[dict]:
|
def _match_items(
|
||||||
"""Элементы списка партий (общее для списка группы и истории игрока)."""
|
session: Session, matches, rating_deltas: dict[int, float] | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Элементы списка партий (общее для списка группы и истории игрока).
|
||||||
|
|
||||||
|
rating_deltas — {match_id: ΔR} владельца истории; у списка группы их нет."""
|
||||||
by_match = _participants_by_match(session, [m.id for m in matches])
|
by_match = _participants_by_match(session, [m.id for m in matches])
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
@@ -375,18 +421,18 @@ def _match_items(session: Session, matches) -> list[dict]:
|
|||||||
"overall_comment": m.overall_comment,
|
"overall_comment": m.overall_comment,
|
||||||
"created_by": m.created_by,
|
"created_by": m.created_by,
|
||||||
"participants": parts,
|
"participants": parts,
|
||||||
|
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||||
total = session.exec(
|
where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
|
||||||
select(func.count()).select_from(Match).where(Match.group_id == group_id)
|
total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
|
||||||
).one()
|
|
||||||
matches = session.exec(
|
matches = session.exec(
|
||||||
select(Match)
|
select(Match)
|
||||||
.where(Match.group_id == group_id)
|
.where(*where)
|
||||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -408,23 +454,29 @@ def user_match_list(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
||||||
|
|
||||||
best_only — одна лучшая партия: наибольший прирост рейтинга в общей цепочке
|
У каждой партии — изменение общего рейтинга игрока за неё (rating_delta, один знак
|
||||||
|
после запятой). best_only — одна лучшая партия: наибольший прирост рейтинга
|
||||||
(учитывает и соперников, и ход партии); при равенстве берём более свежую."""
|
(учитывает и соперников, и ход партии); при равенстве берём более свежую."""
|
||||||
if best_only:
|
|
||||||
history = load_history(session)
|
history = load_history(session)
|
||||||
rep = replay(history)
|
rep = replay(history)
|
||||||
candidates = [
|
mine = _user_seats(history, user_id)
|
||||||
(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in _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 []
|
matches = [session.get(Match, max(candidates)[2])] if candidates else []
|
||||||
return {
|
return {
|
||||||
"items": _match_items(session, matches),
|
"items": _match_items(session, matches, deltas),
|
||||||
"total": len(matches),
|
"total": len(matches),
|
||||||
"limit": 1,
|
"limit": 1,
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
|
where = (
|
||||||
|
MatchParticipant.user_id == user_id,
|
||||||
|
Match.status == "finished",
|
||||||
|
Match.id.in_(_playable_match_ids()),
|
||||||
|
)
|
||||||
total = session.exec(
|
total = session.exec(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Match)
|
.select_from(Match)
|
||||||
@@ -440,7 +492,7 @@ def user_match_list(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return {
|
return {
|
||||||
"items": _match_items(session, matches),
|
"items": _match_items(session, matches, deltas),
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
@@ -478,8 +530,8 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
||||||
# История грузится и общая цепочка проигрывается один раз: из них и топ, и профиль;
|
# История грузится и проигрывается один раз: из неё и топ, и профиль, и блок активной
|
||||||
# итог по активной группе — отдельная (групповая) цепочка по срезу той же истории.
|
# группы (там игры и победы по группе, рейтинг — общий).
|
||||||
history = load_history(session)
|
history = load_history(session)
|
||||||
rep = replay(history)
|
rep = replay(history)
|
||||||
board = leaderboard(session, history=history, rep=rep)
|
board = leaderboard(session, history=history, rep=rep)
|
||||||
@@ -488,11 +540,10 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
|
|||||||
if active_group_id is not None:
|
if active_group_id is not None:
|
||||||
group = session.get(Group, active_group_id)
|
group = session.get(Group, active_group_id)
|
||||||
if group is not None:
|
if group is not None:
|
||||||
group_history = _for_group(history, active_group_id)
|
|
||||||
active_group_brief = {
|
active_group_brief = {
|
||||||
"id": group.id,
|
"id": group.id,
|
||||||
"name": group.name,
|
"name": group.name,
|
||||||
**_overall(group_history, replay(group_history), user_id),
|
**_overall(_for_group(history, active_group_id), rep, user_id),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"leaderboard": board["entries"][:leaderboard_limit],
|
"leaderboard": board["entries"][:leaderboard_limit],
|
||||||
|
|||||||
@@ -380,5 +380,5 @@ def public_profile(session: Session, user_id: int) -> dict:
|
|||||||
"nickname": user.nickname,
|
"nickname": user.nickname,
|
||||||
"bio": user.bio,
|
"bio": user.bio,
|
||||||
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
"stats": stats_service.profile_stats(session, user_id, group_id=None),
|
"stats": stats_service.profile_stats(session, user_id),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
|
|
||||||
|
from app.models import Faction
|
||||||
|
from app.seed.reference_data import FACTIONS, seed_reference_data
|
||||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||||
|
|
||||||
|
|
||||||
@@ -205,6 +209,37 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
|
|||||||
assert ap["faction_name"] == "Орки WAAAGH"
|
assert ap["faction_name"] == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
|
||||||
|
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
|
||||||
|
имя, заданное админом (#72)."""
|
||||||
|
login(client, "Кто-то")
|
||||||
|
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/admin/factions/{orks['id']}",
|
||||||
|
json={"name_ru": "Орки WAAAGH"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
seed_reference_data(s) # то же, что делает рестарт
|
||||||
|
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeding_is_complete_and_idempotent():
|
||||||
|
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
with Session(engine) as s:
|
||||||
|
seed_reference_data(s)
|
||||||
|
seed_reference_data(s)
|
||||||
|
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
|
||||||
|
assert names == {code: name for code, name, _exp, _order in FACTIONS}
|
||||||
|
|
||||||
|
|
||||||
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
||||||
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
|
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
|
||||||
|
стартует с дефолтными секретами (#59, F4, #69)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
@@ -61,10 +64,70 @@ def test_production_skips_admin_check_when_bootstrap_disabled():
|
|||||||
def test_development_allows_defaults():
|
def test_development_allows_defaults():
|
||||||
s = config.Settings(
|
s = config.Settings(
|
||||||
app_env="development",
|
app_env="development",
|
||||||
|
local_public="local",
|
||||||
secret_key=config._DEFAULT_SECRET_KEY,
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
)
|
)
|
||||||
assert s.is_development
|
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", ""])
|
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
||||||
@@ -77,3 +140,20 @@ def test_unknown_app_env_rejected(app_env):
|
|||||||
def test_app_env_case_insensitive():
|
def test_app_env_case_insensitive():
|
||||||
s = config.Settings(app_env="Development")
|
s = config.Settings(app_env="Development")
|
||||||
assert s.is_development
|
assert s.is_development
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("offset", [-13, 15])
|
||||||
|
def test_tz_offset_out_of_range_rejected(offset):
|
||||||
|
with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
|
||||||
|
config.Settings(app_env="development", app_tz_offset_hours=offset)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
|
||||||
|
"""Фронт показывает время в поясе приложения — смещение приходит из настроек."""
|
||||||
|
from app.routers import auth
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
|
||||||
|
assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
|
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
|
||||||
|
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -16,20 +17,29 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
|
|||||||
return published
|
return published
|
||||||
|
|
||||||
|
|
||||||
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
|
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
|
||||||
return [e for _ids, e in published if e.get("type") == "match"]
|
return {uid for ids, e in published if e.get("type") == kind for uid in ids}
|
||||||
|
|
||||||
|
|
||||||
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch):
|
def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
|
||||||
|
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
|
||||||
me = login(client, "Хост")
|
me = login(client, "Хост")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
gid = client.post(
|
|
||||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
def group(name: str) -> int:
|
||||||
|
return client.post(
|
||||||
|
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
).json()["id"]
|
).json()["id"]
|
||||||
|
|
||||||
|
gid, other_gid = group("Группа"), group("Другая")
|
||||||
p2 = add_group_member(engine, gid, "Игрок2")
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
outsider = add_group_member(engine, other_gid, "Чужой")
|
||||||
p3 = add_group_member(engine, gid, "Зритель")
|
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
return me, gid, p2, outsider, fids
|
||||||
|
|
||||||
|
|
||||||
|
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
|
||||||
|
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
started = start_match(
|
started = start_match(
|
||||||
@@ -37,36 +47,31 @@ def test_match_event_carries_participants(client: TestClient, engine, monkeypatc
|
|||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
)
|
)
|
||||||
assert started.status_code == 200, started.text
|
assert started.status_code == 200, started.text
|
||||||
mid = started.json()["id"]
|
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
|
||||||
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
ev = _match_events(published)[-1]
|
assert _recipients(published, "ratings") == set()
|
||||||
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
|
|
||||||
assert p3 not in ev["participant_ids"]
|
|
||||||
|
|
||||||
published.clear()
|
published.clear()
|
||||||
fin = finish_match(
|
fin = finish_match(
|
||||||
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||||
)
|
)
|
||||||
assert fin.status_code == 200, fin.text
|
assert fin.status_code == 200, fin.text
|
||||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
|
ratings = _recipients(published, "ratings")
|
||||||
|
assert outsider in ratings
|
||||||
|
assert not ratings & {me["id"], p2} # группа уже получила подробное событие
|
||||||
|
|
||||||
|
|
||||||
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch):
|
def test_deleting_finished_match_reaches_players_outside_group(
|
||||||
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
|
client: TestClient, engine, monkeypatch
|
||||||
|
):
|
||||||
Если собирать их после удаления, список всегда окажется пустым, и клиент не
|
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
|
||||||
обновит историю тем, кто в этой партии играл."""
|
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||||
me = login(client, "Хост")
|
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
|
||||||
gid = client.post(
|
|
||||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
|
||||||
).json()["id"]
|
|
||||||
p2 = add_group_member(engine, gid, "Игрок2")
|
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
|
||||||
mid = start_match(
|
mid = start_match(
|
||||||
client, gid,
|
client, gid,
|
||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
).json()["id"]
|
).json()["id"]
|
||||||
|
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
version = client.get(f"/api/matches/{mid}").json()["version"]
|
version = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
@@ -74,4 +79,5 @@ def test_delete_event_carries_participants(client: TestClient, engine, monkeypat
|
|||||||
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
|
assert outsider in _recipients(published, "ratings")
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Список игроков группы — только текущий состав (#76).
|
||||||
|
|
||||||
|
Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в
|
||||||
|
истории: они уже повлияли на (общий) рейтинг оставшихся."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(stats: dict) -> dict[str, set[int]]:
|
||||||
|
return {
|
||||||
|
block: {e["user_id"] for e in stats[block]}
|
||||||
|
for block in ("leaderboard", "provisional", "inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _group(client: TestClient) -> tuple[int, list[int]]:
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
return gid, fids
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(client: TestClient, gid: int, uid: int) -> None:
|
||||||
|
r = client.delete(f"/api/groups/{gid}/members/{uid}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_member_leaves_group_rating_but_not_overall(client: TestClient, engine):
|
||||||
|
me = login(client, "Хозяин")
|
||||||
|
gid, fids = _group(client)
|
||||||
|
b = add_group_member(engine, gid, "Ушедший")
|
||||||
|
create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
before = client.get(f"/api/groups/{gid}/stats").json()
|
||||||
|
my_score = next(e["score"] for e in before["provisional"] if e["user_id"] == me["id"])
|
||||||
|
|
||||||
|
_remove(client, gid, b)
|
||||||
|
|
||||||
|
stats = client.get(f"/api/groups/{gid}/stats").json()
|
||||||
|
ids = _ids(stats)
|
||||||
|
assert all(b not in block for block in ids.values())
|
||||||
|
assert me["id"] in ids["provisional"]
|
||||||
|
# Партия с ушедшим учтена: рейтинг оставшегося не изменился, счётчик партий тоже.
|
||||||
|
assert next(e["score"] for e in stats["provisional"] if e["user_id"] == me["id"]) == my_score
|
||||||
|
assert stats["total_matches"] == 1
|
||||||
|
|
||||||
|
board = client.get("/api/stats/leaderboard").json()
|
||||||
|
assert b in {e["user_id"] for e in board["entries"] + board["provisional"]}
|
||||||
|
|
||||||
|
# Вернули в группу — снова в списке со своей историей.
|
||||||
|
add_group_member(engine, gid, "Ушедший")
|
||||||
|
back = client.get(f"/api/groups/{gid}/stats").json()
|
||||||
|
entry = next(e for e in back["provisional"] if e["user_id"] == b)
|
||||||
|
assert (entry["games"], entry["score"]) == (1, 1468)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_ranks_renumbered_after_removal(client: TestClient, engine):
|
||||||
|
me = login(client, "Первый")
|
||||||
|
gid, fids = _group(client)
|
||||||
|
b = add_group_member(engine, gid, "Второй")
|
||||||
|
c = add_group_member(engine, gid, "Третий")
|
||||||
|
for _ in range(10):
|
||||||
|
create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": fids[1], "place": 2},
|
||||||
|
{"user_id": c, "faction_id": fids[2], "place": 3},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert [e["rank"] for e in client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]] == [1, 2, 3]
|
||||||
|
|
||||||
|
_remove(client, gid, b)
|
||||||
|
|
||||||
|
board = client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]
|
||||||
|
assert [(e["user_id"], e["rank"]) for e in board] == [(me["id"], 1), (c, 2)]
|
||||||
@@ -68,7 +68,7 @@ EXAMPLES = [
|
|||||||
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
||||||
"objectives", end_round=7,
|
"objectives", end_round=7,
|
||||||
),
|
),
|
||||||
{A: 9.27, B: 5.85, C: -7.78, D: -7.35}),
|
{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)),
|
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
||||||
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
|
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}),
|
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
|
||||||
@@ -101,6 +101,23 @@ def test_last_standing_counts_full_objective_gap():
|
|||||||
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
|
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"
|
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
|
||||||
@@ -147,6 +164,7 @@ def test_constants_match_reference(sim):
|
|||||||
assert dict(p.closeness) == scoring.CLOSENESS
|
assert dict(p.closeness) == scoring.CLOSENESS
|
||||||
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
||||||
assert not p.autocorr
|
assert not p.autocorr
|
||||||
|
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
||||||
@@ -174,8 +192,8 @@ def test_replay_matches_reference_season(sim, scenario, stripped):
|
|||||||
|
|
||||||
|
|
||||||
def test_monotone_and_zero_sum(sim):
|
def test_monotone_and_zero_sum(sim):
|
||||||
"""Победитель без ничьей не теряет, последний без ничьей не получает; при равных K
|
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
|
||||||
сумма изменений за партию — ноль."""
|
при равных K сумма изменений за партию — ноль."""
|
||||||
cfg = sim.SCENARIOS["сигнал"]
|
cfg = sim.SCENARIOS["сигнал"]
|
||||||
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
||||||
ids: dict[str, int] = {}
|
ids: dict[str, int] = {}
|
||||||
@@ -188,6 +206,8 @@ def test_monotone_and_zero_sum(sim):
|
|||||||
delta, _ = rate_match({}, veterans, rm)
|
delta, _ = rate_match({}, veterans, rm)
|
||||||
places = [s.place for s in rm.seats]
|
places = [s.place for s in rm.seats]
|
||||||
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:
|
if places.count(s.place) > 1:
|
||||||
continue
|
continue
|
||||||
if s.place == 1:
|
if s.place == 1:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Рейтинг в витринах (#23): Elo по упорядоченной истории, общая и групповая цепочки."""
|
"""Рейтинг в витринах (#23, #80): Elo по упорядоченной истории, один рейтинг на игрока."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -100,8 +100,9 @@ def test_editing_past_match_recalculates_later_ones(client: TestClient, engine):
|
|||||||
assert {uid: e["score"] for uid, e in _board(client).items()} == after
|
assert {uid: e["score"] for uid, e in _board(client).items()} == after
|
||||||
|
|
||||||
|
|
||||||
def test_group_rating_is_its_own_chain(client: TestClient, engine):
|
def test_group_page_shows_overall_rating_with_group_stats(client: TestClient, engine):
|
||||||
"""Групповой рейтинг проигрывает только партии группы, общий — все партии."""
|
"""Рейтинг один на всё приложение (#80): в группе он тот же, что в общем топе,
|
||||||
|
а игры и победы — только по партиям группы."""
|
||||||
me = login(client, "Путешественник")
|
me = login(client, "Путешественник")
|
||||||
g1, f1 = _group(client, "Первая")
|
g1, f1 = _group(client, "Первая")
|
||||||
g2, f2 = _group(client, "Вторая")
|
g2, f2 = _group(client, "Вторая")
|
||||||
@@ -110,16 +111,55 @@ def test_group_rating_is_its_own_chain(client: TestClient, engine):
|
|||||||
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
|
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
|
||||||
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
|
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
|
||||||
|
|
||||||
assert _board(client, f"/api/groups/{g1}/stats")[me["id"]]["score"] == 1532
|
overall = _board(client)[me["id"]]
|
||||||
assert _board(client, f"/api/groups/{g2}/stats")[me["id"]]["score"] == 1468
|
assert overall["games"] == 2
|
||||||
overall = _board(client)[me["id"]]["score"]
|
assert overall["score"] == round(
|
||||||
assert overall not in (1532, 1468)
|
|
||||||
assert overall == round(
|
|
||||||
replay([
|
replay([
|
||||||
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
|
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
|
||||||
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
|
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
|
||||||
]).ratings[me["id"]]
|
]).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):
|
def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
|
||||||
@@ -138,3 +178,63 @@ def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
|
|||||||
data = client.get(f"/api/users/{me['id']}/matches").json()
|
data = client.get(f"/api/users/{me['id']}/matches").json()
|
||||||
assert data["total"] == 1
|
assert data["total"] == 1
|
||||||
assert data["items"][0]["id"] == first["id"]
|
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
|
||||||
|
|||||||
@@ -52,17 +52,17 @@ def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
|
|||||||
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
||||||
)
|
)
|
||||||
|
|
||||||
calls: list[int | None] = []
|
calls: list[int] = []
|
||||||
original = stats_service.load_history
|
original = stats_service.load_history
|
||||||
|
|
||||||
def spy(session, group_id=None):
|
def spy(session):
|
||||||
calls.append(group_id)
|
calls.append(1)
|
||||||
return original(session, group_id)
|
return original(session)
|
||||||
|
|
||||||
monkeypatch.setattr(stats_service, "load_history", spy)
|
monkeypatch.setattr(stats_service, "load_history", spy)
|
||||||
r = client.get("/api/home")
|
r = client.get("/api/home")
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert calls == [None]
|
assert len(calls) == 1
|
||||||
# Главная всё ещё показывает и профиль, и блок активной группы.
|
# Главная всё ещё показывает и профиль, и блок активной группы.
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["profile"]["overall"]["games"] == 2
|
assert body["profile"]["overall"]["games"] == 2
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
FROM restic/restic:0.19.1
|
FROM restic/restic:0.19.1
|
||||||
|
|
||||||
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
|
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
|
||||||
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
# tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
|
||||||
RUN apk add --no-cache sqlite supercronic tini
|
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
||||||
|
RUN apk add --no-cache sqlite supercronic tini curl
|
||||||
|
|
||||||
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
||||||
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
||||||
|
|||||||
+55
-5
@@ -737,16 +737,23 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
||||||
Контейнер `backup` стартует одновременно с `app` и сразу пробует сделать первый бэкап. В его
|
Контейнер `backup` стартует одновременно с `app`, ждёт, пока приложение ответит (до
|
||||||
журнале (`docker compose logs backup`) нормально увидеть одно из двух:
|
10 минут), и пробует сделать первый бэкап. В его журнале (`docker compose logs backup`)
|
||||||
|
нормально увидеть одно из трёх:
|
||||||
|
|
||||||
- если `backup` успел раньше, чем `app` создал БД:
|
- если приложение так и не поднялось и БД нет:
|
||||||
|
|
||||||
```
|
```
|
||||||
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
|
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
|
||||||
Первый бэкап не удался — следующая попытка по расписанию.
|
Первый бэкап не удался — следующая попытка по расписанию.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- если БД есть, но без таблиц (миграции ещё не прошли):
|
||||||
|
|
||||||
|
```
|
||||||
|
Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches.
|
||||||
|
```
|
||||||
|
|
||||||
- если БД уже была:
|
- если БД уже была:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -763,8 +770,8 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
|
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
|
||||||
Защита выше не распознаёт БД без таблиц: если первый бэкап попал ровно в момент создания
|
Снимки без данных защита больше не создаёт, но снимок с `?` мог остаться от старой версии
|
||||||
БД, в списке может появиться свежий снимок с `?` (задача #74). Такой снимок не выбирайте.
|
бэкапов — такой не выбирайте.
|
||||||
|
|
||||||
5. Восстановите, подставив ID из `list vps`:
|
5. Восстановите, подставив ID из `list vps`:
|
||||||
|
|
||||||
@@ -831,6 +838,42 @@ ID одного и того же снимка в `local` и `vps` разные
|
|||||||
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
||||||
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
||||||
|
|
||||||
|
### Отчёты в Telegram
|
||||||
|
|
||||||
|
Бот приложения (тот же, что для входа через Telegram) может присылать вам отчёты о бэкапах
|
||||||
|
и отвечать на команды. Пока `BACKUP_TELEGRAM_CHAT_ID` пуст, отчёты выключены.
|
||||||
|
|
||||||
|
**Что приходит само:**
|
||||||
|
- после каждого бэкапа — «✅ Бэкап»: сколько игроков и партий в БД, по каждому репозиторию
|
||||||
|
снимок, число снимков и размер;
|
||||||
|
- если бэкап не удался, в том числе не начавшись (нет пароля, занята другая операция, БД
|
||||||
|
не прошла проверку) — «❌ Бэкап не удался» с причиной и упавшими репозиториями;
|
||||||
|
- после еженедельной проверки — «🔍 Проверка данных: OK» или «❌».
|
||||||
|
|
||||||
|
**Команды боту** (отвечает только чатам из `BACKUP_TELEGRAM_CHAT_ID`):
|
||||||
|
- `/backups` — хранящиеся снимки: по репозиторию число и размер, 10 последних с временем,
|
||||||
|
игроками и партиями, 📌 у именованных;
|
||||||
|
- `/status` — то же, что `fs-backup status`;
|
||||||
|
- `/help` — список команд.
|
||||||
|
|
||||||
|
**Настройка:**
|
||||||
|
1. В Telegram найдите бота приложения и напишите ему `/start`.
|
||||||
|
2. Узнайте id своего чата:
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup telegram chats
|
||||||
|
# 123456789 @you /start
|
||||||
|
```
|
||||||
|
Когда бот уже работает (id вписан), он сам забирает сообщения. Тогда id нового чата
|
||||||
|
ищите в журнале: `docker compose logs backup | grep "чужого чата"`.
|
||||||
|
3. Впишите id в `.env` — `BACKUP_TELEGRAM_CHAT_ID=123456789`, несколько через запятую —
|
||||||
|
и пересоздайте контейнер: `docker compose up -d backup`.
|
||||||
|
4. Проверьте связь: `docker compose exec backup fs-backup telegram test` — в чат придёт
|
||||||
|
пробное сообщение.
|
||||||
|
|
||||||
|
Токен бота по умолчанию берётся из `TELEGRAM_BOT_TOKEN` приложения. Чтобы слать отчёты от
|
||||||
|
другого бота, задайте `BACKUP_TELEGRAM_BOT_TOKEN`. Если Telegram недоступен, бэкапы работают
|
||||||
|
как обычно — в журнале будет только строка «Telegram: … не отправлено».
|
||||||
|
|
||||||
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
||||||
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
||||||
> не трогает.
|
> не трогает.
|
||||||
@@ -852,6 +895,8 @@ docker compose logs --tail 100 backup
|
|||||||
| Симптом | Причина | Что сделать |
|
| Симптом | Причина | Что сделать |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
||||||
|
| отчёты в Telegram не приходят, `telegram test` пишет «отправить не удалось» | неверный chat id или токен, боту не писали `/start` | раздел 10, «Отчёты в Telegram»: написать боту, взять id из журнала, `docker compose up -d backup` |
|
||||||
|
| бот не отвечает на `/backups`, в журнале «сообщение из чужого чата» | ваш id не в `BACKUP_TELEGRAM_CHAT_ID` | вписать id из этой строки журнала, `docker compose up -d backup` |
|
||||||
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
||||||
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
||||||
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
||||||
@@ -920,6 +965,9 @@ docker volume rm <имя тома>
|
|||||||
| `recover` | разбор прерванного восстановления |
|
| `recover` | разбор прерванного восстановления |
|
||||||
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
||||||
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
||||||
|
| `telegram chats` | id чатов, писавших боту, — для `BACKUP_TELEGRAM_CHAT_ID` |
|
||||||
|
| `telegram test` | пробное сообщение в чаты `BACKUP_TELEGRAM_CHAT_ID` |
|
||||||
|
| `telegram bot` | бот-слушатель команд `/backups`, `/status` (контейнер запускает его сам) |
|
||||||
| `help` | краткая справка |
|
| `help` | краткая справка |
|
||||||
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
||||||
|
|
||||||
@@ -955,6 +1003,8 @@ docker volume rm <имя тома>
|
|||||||
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
||||||
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
||||||
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
||||||
|
| `BACKUP_TELEGRAM_CHAT_ID` | Pi | — | id чатов для отчётов и команд, через запятую; пусто = без Telegram |
|
||||||
|
| `BACKUP_TELEGRAM_BOT_TOKEN` | Pi | `TELEGRAM_BOT_TOKEN` | токен бота, если отчёты должен слать другой бот |
|
||||||
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
||||||
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
||||||
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ set -eu
|
|||||||
|
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
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-петля и спам в логах) — ждём,
|
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
||||||
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
||||||
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
||||||
@@ -29,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if ! fs-backup has-snapshots; then
|
if ! fs-backup has-snapshots; then
|
||||||
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу."
|
# Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
|
||||||
|
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
|
||||||
|
# равно: guard_empty не сохранит БД без таблиц.
|
||||||
|
_app="${BACKUP_APP_HOST:-app}"
|
||||||
|
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
|
||||||
|
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
|
||||||
|
_waited=0
|
||||||
|
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
|
||||||
|
if [ "$_waited" -ge "$_wait" ]; then
|
||||||
|
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
_waited=$((_waited + 10))
|
||||||
|
done
|
||||||
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+243
-4
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
|
|||||||
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
|
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
|
||||||
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
|
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
|
||||||
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
|
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
|
||||||
|
TG_TOKEN="${BACKUP_TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
TG_CHATS="${BACKUP_TELEGRAM_CHAT_ID:-}"
|
||||||
|
TG_API="${BACKUP_TELEGRAM_API:-https://api.telegram.org}"
|
||||||
|
LAST_ERROR="" # текст последнего die — причина сбоя в отчёте Telegram
|
||||||
|
|
||||||
# ─── Общие функции ────────────────────────────────────────────────────────────
|
# ─── Общие функции ────────────────────────────────────────────────────────────
|
||||||
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
||||||
die() { log "ОШИБКА: $*"; exit 1; }
|
die() { LAST_ERROR="$*"; log "ОШИБКА: $*"; exit 1; }
|
||||||
|
|
||||||
require_password() {
|
require_password() {
|
||||||
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
||||||
@@ -118,6 +122,99 @@ human() { # байты → «12.3 MB»
|
|||||||
|
|
||||||
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
||||||
|
|
||||||
|
# ─── Telegram (#83) ───────────────────────────────────────────────────────────
|
||||||
|
# Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота
|
||||||
|
# BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не
|
||||||
|
# страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст.
|
||||||
|
tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; }
|
||||||
|
|
||||||
|
tg_escape() { sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'; }
|
||||||
|
|
||||||
|
tg_chat_ids() { printf '%s' "$TG_CHATS" | tr ',;' ' '; }
|
||||||
|
|
||||||
|
tg_api() { # tg_api <метод> [аргументы curl…] → JSON ответа
|
||||||
|
_method="$1"
|
||||||
|
shift
|
||||||
|
curl -fsS --max-time 70 "$@" "$TG_API/bot$TG_TOKEN/$_method" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_post() { # tg_post <chat_id> <html> — одно сообщение в один чат
|
||||||
|
tg_api sendMessage -o /dev/null \
|
||||||
|
--data-urlencode "chat_id=$1" \
|
||||||
|
--data-urlencode "text=$2" \
|
||||||
|
--data-urlencode "parse_mode=HTML" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true"
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_send() { # tg_send <html> — во все чаты владельца
|
||||||
|
tg_enabled || return 0
|
||||||
|
for _chat in $(tg_chat_ids); do
|
||||||
|
tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
repo_summary() { # repo_summary <repo> → «снимков 30 · 45.2 MB»
|
||||||
|
if _st="$(r "$1" stats --mode raw-data --json 2>/dev/null)"; then
|
||||||
|
printf '%s' "$_st" | jq -r '"\(.snapshots_count)\t\(.total_size)"' | {
|
||||||
|
IFS="$(printf '\t')" read -r _cnt _size
|
||||||
|
echo "снимков $_cnt · $(human "$_size")"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "размер недоступен"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Итог run/verify одним сообщением. Списки — «репо:поле:поле» через пробел; их собирают
|
||||||
|
# cmd_run/cmd_verify, а вызывает обработчик EXIT — так отчёт уходит и при раннем отказе.
|
||||||
|
notify_run() { # notify_run <код выхода>
|
||||||
|
tg_enabled || return 0
|
||||||
|
_when="$(date '+%d.%m %H:%M')"
|
||||||
|
_db="БД: игроков $_players, партий $_matches"
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
_text="✅ <b>Бэкап</b> $_when
|
||||||
|
$_db"
|
||||||
|
else
|
||||||
|
_text="❌ <b>Бэкап не удался</b> $_when
|
||||||
|
$(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)"
|
||||||
|
[ "$_players" = "?" ] || _text="$_text
|
||||||
|
$_db"
|
||||||
|
fi
|
||||||
|
for _item in $_ok_repos; do
|
||||||
|
_text="$_text
|
||||||
|
• ${_item%%:*}: снимок <code>${_item#*:}</code> · $(repo_summary "${_item%%:*}")"
|
||||||
|
done
|
||||||
|
for _repo in $_failed; do
|
||||||
|
_text="$_text
|
||||||
|
• $_repo: ошибка"
|
||||||
|
done
|
||||||
|
[ "$1" -eq 0 ] || _text="$_text
|
||||||
|
Подробности: <code>docker compose logs backup</code>"
|
||||||
|
tg_send "$_text"
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_verify() { # notify_verify <код выхода>
|
||||||
|
tg_enabled || return 0
|
||||||
|
_when="$(date '+%d.%m %H:%M')"
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
_text="🔍 <b>Проверка данных: OK</b> $_when"
|
||||||
|
else
|
||||||
|
_text="❌ <b>Проверка данных не прошла</b> $_when
|
||||||
|
$(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)"
|
||||||
|
fi
|
||||||
|
for _item in $_verified; do
|
||||||
|
_rest="${_item#*:}"
|
||||||
|
_text="$_text
|
||||||
|
• ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}"
|
||||||
|
done
|
||||||
|
for _repo in $_failed; do
|
||||||
|
_text="$_text
|
||||||
|
• $_repo: ошибка"
|
||||||
|
done
|
||||||
|
[ "$1" -eq 0 ] || _text="$_text
|
||||||
|
Подробности: <code>docker compose logs backup</code>"
|
||||||
|
tg_send "$_text"
|
||||||
|
}
|
||||||
|
|
||||||
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
||||||
db_ok() {
|
db_ok() {
|
||||||
[ -s "$1" ] || return 1
|
[ -s "$1" ] || return 1
|
||||||
@@ -178,6 +275,17 @@ forget_repo() {
|
|||||||
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
||||||
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
||||||
guard_empty() { # guard_empty <игроков> <партий>
|
guard_empty() { # guard_empty <игроков> <партий>
|
||||||
|
# «?» — счётчики не прочитались: в БД нет таблиц (миграции ещё не прошли). Такой снимок
|
||||||
|
# стал бы latest и сломал бы restore latest (#74) — отказ, как и для пустой БД.
|
||||||
|
case "$1$2" in
|
||||||
|
*'?'*)
|
||||||
|
for _r in $(repos); do mark "run-$_r" err "в БД нет таблиц users/matches — бэкап не сделан"; done
|
||||||
|
log "Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches."
|
||||||
|
log "Похоже, приложение ещё не применило миграции. Бэкап НЕ сделан, чтобы снимок без данных"
|
||||||
|
log "не стал последним. Дождитесь запуска приложения; если так и задумано:"
|
||||||
|
die "fs-backup run --allow-empty"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
|
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
|
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
|
||||||
@@ -236,11 +344,18 @@ cmd_run() {
|
|||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
_players="?"
|
||||||
|
_matches="?"
|
||||||
|
_ok_repos=""
|
||||||
|
_failed=""
|
||||||
|
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
|
||||||
|
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
|
||||||
|
trap '_rc=$?; notify_run "$_rc"' EXIT
|
||||||
require_password
|
require_password
|
||||||
setup_ssh
|
setup_ssh
|
||||||
take_lock
|
take_lock
|
||||||
warn_leftovers
|
warn_leftovers
|
||||||
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
|
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
|
||||||
|
|
||||||
log "Снимок данных: консистентная копия БД…"
|
log "Снимок данных: консистентная копия БД…"
|
||||||
stage_db
|
stage_db
|
||||||
@@ -252,7 +367,6 @@ cmd_run() {
|
|||||||
log "БД в порядке: игроков $_players, партий $_matches."
|
log "БД в порядке: игроков $_players, партий $_matches."
|
||||||
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
||||||
|
|
||||||
_failed=""
|
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
||||||
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
||||||
@@ -260,6 +374,7 @@ cmd_run() {
|
|||||||
&& r "$_repo" check >&2; then
|
&& r "$_repo" check >&2; then
|
||||||
_sid="$(latest_short_id "$_repo")"
|
_sid="$(latest_short_id "$_repo")"
|
||||||
mark "run-$_repo" ok "снимок $_sid"
|
mark "run-$_repo" ok "снимок $_sid"
|
||||||
|
_ok_repos="$_ok_repos $_repo:$_sid"
|
||||||
log "OK: репозиторий $_repo, снимок $_sid."
|
log "OK: репозиторий $_repo, снимок $_sid."
|
||||||
else
|
else
|
||||||
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
||||||
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
|
|||||||
|
|
||||||
# ─── verify ───────────────────────────────────────────────────────────────────
|
# ─── verify ───────────────────────────────────────────────────────────────────
|
||||||
cmd_verify() {
|
cmd_verify() {
|
||||||
|
_verified=""
|
||||||
|
_failed=""
|
||||||
|
trap '_rc=$?; notify_verify "$_rc"' EXIT
|
||||||
require_password
|
require_password
|
||||||
setup_ssh
|
setup_ssh
|
||||||
take_lock
|
take_lock
|
||||||
_failed=""
|
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
||||||
_tmp="$RUNTIME_DIR/verify.db"
|
_tmp="$RUNTIME_DIR/verify.db"
|
||||||
@@ -378,6 +495,7 @@ cmd_verify() {
|
|||||||
&& db_ok "$_tmp"; then
|
&& db_ok "$_tmp"; then
|
||||||
_counts="$(db_counts "$_tmp")"
|
_counts="$(db_counts "$_tmp")"
|
||||||
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
||||||
|
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
|
||||||
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
||||||
else
|
else
|
||||||
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
||||||
@@ -779,6 +897,121 @@ cmd_init() {
|
|||||||
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── telegram ─────────────────────────────────────────────────────────────────
|
||||||
|
tg_help_text() {
|
||||||
|
cat <<'EOF'
|
||||||
|
<b>Бэкапы Forbidden Stars</b>
|
||||||
|
/backups — хранящиеся снимки по репозиториям
|
||||||
|
/status — последние бэкапы и проверки, размеры
|
||||||
|
Отчёт о каждом бэкапе и проверке приходит сюда сам.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних
|
||||||
|
[ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; }
|
||||||
|
setup_ssh
|
||||||
|
for _repo in $(repos); do
|
||||||
|
echo "<b>$_repo</b> · $(repo_summary "$_repo")"
|
||||||
|
if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then
|
||||||
|
printf '%s' "$_json" | jq -r '
|
||||||
|
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
|
||||||
|
def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)];
|
||||||
|
sort_by(.time) | .[-10:] | reverse | .[] |
|
||||||
|
"<code>\(.time[5:16] | sub("T"; " "))</code> игроков \(tagval("players:")), партий \(tagval("matches:"))"
|
||||||
|
+ (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)'
|
||||||
|
else
|
||||||
|
echo "репозиторий недоступен или занят — повторите позже"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_allowed() { # tg_allowed <chat_id> — чат из белого списка владельца
|
||||||
|
for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_reply() { # tg_reply <chat_id> <текст команды>
|
||||||
|
# Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота.
|
||||||
|
case "$2" in
|
||||||
|
/backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;;
|
||||||
|
/status*) _reply="<pre>$(cmd_status 2>&1 | head -n 60 | tg_escape)</pre>" ;;
|
||||||
|
*) _reply="$(tg_help_text)" ;;
|
||||||
|
esac
|
||||||
|
[ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup."
|
||||||
|
tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие
|
||||||
|
# пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение
|
||||||
|
# restic совместимо с идущим бэкапом.
|
||||||
|
cmd_telegram_bot() {
|
||||||
|
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота."
|
||||||
|
_offset_file="$STATE_DIR/telegram.offset"
|
||||||
|
_updates="$RUNTIME_DIR/telegram-updates"
|
||||||
|
log "Telegram-бот: слушаю команды /backups, /status, /help."
|
||||||
|
while :; do
|
||||||
|
_offset="$(cat "$_offset_file" 2>/dev/null || echo 0)"
|
||||||
|
if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \
|
||||||
|
--data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then
|
||||||
|
sleep 30
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются.
|
||||||
|
printf '%s' "$_resp" | jq -r '.result[]? |
|
||||||
|
[.update_id, (.message.chat.id // ""), (.message.from.username // ""),
|
||||||
|
((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \
|
||||||
|
> "$_updates" 2>/dev/null || { sleep 30; continue; }
|
||||||
|
while IFS="$(printf '\037')" read -r _uid _chat _from _text; do
|
||||||
|
echo $((_uid + 1)) > "$_offset_file"
|
||||||
|
[ -n "$_chat" ] || continue
|
||||||
|
if tg_allowed "$_chat"; then
|
||||||
|
tg_reply "$_chat" "$_text"
|
||||||
|
else
|
||||||
|
log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID."
|
||||||
|
fi
|
||||||
|
done < "$_updates"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не
|
||||||
|
# подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он
|
||||||
|
# забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»).
|
||||||
|
cmd_telegram_chats() {
|
||||||
|
[ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN."
|
||||||
|
_resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \
|
||||||
|
|| die "Telegram не ответил (неверный токен или нет сети)."
|
||||||
|
_chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null)
|
||||||
|
| "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')"
|
||||||
|
if [ -z "$_chats" ]; then
|
||||||
|
echo "Сообщений боту нет: напишите ему /start в Telegram и повторите."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):"
|
||||||
|
printf '%s\n' "$_chats"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_telegram() {
|
||||||
|
case "${1:-}" in
|
||||||
|
bot) cmd_telegram_bot ;;
|
||||||
|
chats) cmd_telegram_chats ;;
|
||||||
|
test)
|
||||||
|
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)."
|
||||||
|
_sent=0
|
||||||
|
for _chat in $(tg_chat_ids); do
|
||||||
|
if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then
|
||||||
|
_sent=$((_sent + 1))
|
||||||
|
log "Telegram: тестовое сообщение в чат $_chat отправлено."
|
||||||
|
else
|
||||||
|
log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло."
|
||||||
|
;;
|
||||||
|
*) die "telegram: укажите chats, test или bot" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
||||||
@@ -806,6 +1039,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из
|
|||||||
export <id|latest> [--repo local|vps] > fs.tar
|
export <id|latest> [--repo local|vps] > fs.tar
|
||||||
снимок в tar без сжатия (нужен exec -T)
|
снимок в tar без сжатия (нужен exec -T)
|
||||||
|
|
||||||
|
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
|
||||||
|
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
|
||||||
|
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
|
||||||
|
telegram bot бот-слушатель команд (контейнер запускает его сам)
|
||||||
|
|
||||||
Прочее:
|
Прочее:
|
||||||
init создать репозитории (делается автоматически)
|
init создать репозитории (делается автоматически)
|
||||||
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
||||||
@@ -833,6 +1071,7 @@ case "$_cmd" in
|
|||||||
restic) cmd_restic "$@" ;;
|
restic) cmd_restic "$@" ;;
|
||||||
health) cmd_health "$@" ;;
|
health) cmd_health "$@" ;;
|
||||||
has-snapshots) cmd_has_snapshots "$@" ;;
|
has-snapshots) cmd_has_snapshots "$@" ;;
|
||||||
|
telegram) cmd_telegram "$@" ;;
|
||||||
help|-h|--help) usage ;;
|
help|-h|--help) usage ;;
|
||||||
*) usage >&2; exit 2 ;;
|
*) usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+41
-3
@@ -35,6 +35,32 @@ newgrp docker # применить группу
|
|||||||
docker version && docker compose version # проверка
|
docker version && docker compose version # проверка
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Лимиты памяти (memory cgroup)
|
||||||
|
Прошивка Raspberry Pi сама добавляет ядру `cgroup_disable=memory`. Без контроллера `memory`
|
||||||
|
Docker **молча игнорирует** `mem_limit` из `docker-compose.yml` (app 512m, tunnel 64m, backup
|
||||||
|
384m) и на каждый контейнер пишет «Your kernel does not support memory limit capabilities or
|
||||||
|
the cgroup is not mounted. Limitation discarded.». Тогда утечка или тяжёлый бэкап могут
|
||||||
|
занять всю RAM Pi, и OOM-killer прибьёт что попало.
|
||||||
|
|
||||||
|
Проверка — если есть вывод, лимиты не работают:
|
||||||
|
```bash
|
||||||
|
docker info 2>&1 | grep -i "no memory limit"
|
||||||
|
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
|
||||||
|
```
|
||||||
|
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки
|
||||||
|
в этом файле ломает загрузку) и перезагрузить Pi. Прод на время перезагрузки недоступен,
|
||||||
|
контейнеры поднимутся сами:
|
||||||
|
```bash
|
||||||
|
sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak
|
||||||
|
grep -q "cgroup_enable=memory" /boot/firmware/cmdline.txt || \
|
||||||
|
sudo sed -i '1 s/$/ cgroup_enable=memory/' /boot/firmware/cmdline.txt
|
||||||
|
cat /boot/firmware/cmdline.txt # одна строка, в конце cgroup_enable=memory
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
После перезагрузки `cgroup.controllers` содержит `memory`, а `docker info` не пишет
|
||||||
|
`No memory limit support`. Если Pi не загрузился, верните бэкап: вставьте карту в ПК и на
|
||||||
|
разделе `bootfs` замените `cmdline.txt` содержимым `cmdline.txt.bak`.
|
||||||
|
|
||||||
## 2. SSH-ключ для туннеля
|
## 2. SSH-ключ для туннеля
|
||||||
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
||||||
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
||||||
@@ -92,9 +118,18 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
||||||
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
||||||
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются **только при первом создании** админа: если
|
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
|
||||||
> потом поменять их в `.env`, у существующего админа ничего не изменится, а штатной смены
|
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
|
||||||
> пароля админа на проде пока нет (задача #73).
|
>
|
||||||
|
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
|
||||||
|
> его не сменить:
|
||||||
|
> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi;
|
||||||
|
> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env`
|
||||||
|
> только при создании; без этого шага команда ниже увидит старый пароль);
|
||||||
|
> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`.
|
||||||
|
>
|
||||||
|
> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются.
|
||||||
|
> Логин (`ADMIN_USERNAME`) команда не меняет.
|
||||||
|
|
||||||
## 4. Запуск
|
## 4. Запуск
|
||||||
```bash
|
```bash
|
||||||
@@ -145,3 +180,6 @@ docker compose exec backup fs-backup list # хронология снимк
|
|||||||
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
||||||
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
||||||
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
||||||
|
- `docker compose up` пишет «Your kernel does not support memory limit capabilities…
|
||||||
|
Limitation discarded.» → лимиты памяти не работают: включите memory cgroup, раздел 1,
|
||||||
|
«Лимиты памяти».
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
||||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||||
#
|
#
|
||||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
|
||||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
||||||
#
|
#
|
||||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||||
|
|||||||
+11
-5
@@ -21,8 +21,8 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется
|
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
|
||||||
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi).
|
# .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
||||||
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -42,7 +42,9 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
mem_limit: ${APP_MEM_LIMIT:-512m} # лимиты под Pi; переопределяются в .env
|
# Лимиты под Pi; переопределяются в .env. mem_limit действует, только если на Pi включён
|
||||||
|
# memory cgroup — иначе Docker его молча игнорирует (deploy/pi/README.md, раздел 1).
|
||||||
|
mem_limit: ${APP_MEM_LIMIT:-512m}
|
||||||
cpus: ${APP_CPUS:-1.5}
|
cpus: ${APP_CPUS:-1.5}
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -75,8 +77,8 @@ services:
|
|||||||
|
|
||||||
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
||||||
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
||||||
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
|
# только BACKUP_* (секреты приложения сюда не передаются; исключение — токен бота для
|
||||||
# восстановление — deploy/backup/README.md.
|
# отчётов в Telegram). Всё про настройку и восстановление — deploy/backup/README.md.
|
||||||
backup:
|
backup:
|
||||||
build: ./deploy/backup
|
build: ./deploy/backup
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
||||||
@@ -97,6 +99,10 @@ services:
|
|||||||
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
|
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
|
||||||
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
|
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
|
||||||
BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-}
|
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}
|
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/fs-db # живая БД (снимается консистентно)
|
- db-data:/fs-db # живая БД (снимается консистентно)
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
влияет, но слабее темпа.
|
влияет, но слабее темпа.
|
||||||
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
||||||
задачи «60 против 40» в ней — 1600 против 1400.
|
задачи «60 против 40» в ней — 1600 против 1400.
|
||||||
- **Монотонность.** Победитель никогда не теряет рейтинг, последнее место никогда не
|
- **Выбывшие между собой не сравниваются.** Все они проиграли, а миров у них нет: пара двух
|
||||||
приносит.
|
выбывших в расчёт не входит. Сила соперников при этом важна, как и прежде (4.4).
|
||||||
|
- **Монотонность.** Единоличный победитель и выбывшие всегда движутся в свою сторону: первый
|
||||||
|
не теряет рейтинг, вторые его не получают. Внутри ничьей невыбывших — за 1-е или
|
||||||
|
последнее место — пара может сдвинуть рейтинг в любую сторону (4.9).
|
||||||
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
||||||
признаки берутся нейтральными.
|
признаки берутся нейтральными.
|
||||||
|
|
||||||
@@ -159,7 +162,8 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
|||||||
### 4.3. Фактический результат пары
|
### 4.3. Фактический результат пары
|
||||||
|
|
||||||
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
||||||
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`).
|
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`),
|
||||||
|
но между собой не сравниваются (4.4).
|
||||||
|
|
||||||
### 4.4. Изменение рейтинга
|
### 4.4. Изменение рейтинга
|
||||||
|
|
||||||
@@ -170,6 +174,14 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
|||||||
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
||||||
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
||||||
|
|
||||||
|
**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба
|
||||||
|
выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они
|
||||||
|
все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего
|
||||||
|
с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна.
|
||||||
|
Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая
|
||||||
|
пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший
|
||||||
|
получал рейтинг за счёт сильных выбывших.
|
||||||
|
|
||||||
### 4.5. Коэффициент K — скорость изменения
|
### 4.5. Коэффициент K — скорость изменения
|
||||||
|
|
||||||
```math
|
```math
|
||||||
@@ -236,7 +248,10 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 4.9. Свойства
|
### 4.9. Свойства
|
||||||
|
|
||||||
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
||||||
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг.
|
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже:
|
||||||
|
его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей
|
||||||
|
невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший
|
||||||
|
1-е место со слабым, может потерять.
|
||||||
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
||||||
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
||||||
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
||||||
@@ -352,16 +367,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
| A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 |
|
| 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–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 |
|
| B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 |
|
||||||
| C–D | 0.5 | 0.543 | 1/4 (по модулю) | 0 | 1 − 0.125 − 0.25 = **0.625** | −0.027 |
|
| C–D | — | — | — | — | оба выбыли — пара не считается (4.4) | 0 |
|
||||||
|
|
||||||
- `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
|
- `Δ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_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
|
||||||
- `ΔR_C = 6.67 · (−0.551 − 0.589 − 0.027)` = **−7.78**
|
- `ΔR_C = 6.67 · (−0.551 − 0.589)` = **−7.60**
|
||||||
- `ΔR_D = 6.67 · (−0.540 − 0.589 + 0.027)` = **−7.35**
|
- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53**
|
||||||
|
|
||||||
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
||||||
от выбывших огромный — эти пары весят 1.25–1.5. C и D поделили место, но C сильнее по
|
от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба
|
||||||
рейтингу, поэтому немного уступает D.
|
проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего.
|
||||||
|
|
||||||
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
||||||
|
|
||||||
@@ -435,7 +450,9 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 7.3. Результаты: 200 сезонов на сценарий
|
### 7.3. Результаты: 200 сезонов на сценарий
|
||||||
|
|
||||||
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
||||||
так будет считаться история, накопленная до #23.
|
так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны
|
||||||
|
с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь
|
||||||
|
в третьем знаке, у остальных систем цифры прежние.
|
||||||
|
|
||||||
**Сигнал** (потолок точности 0.6843)
|
**Сигнал** (потолок точности 0.6843)
|
||||||
|
|
||||||
@@ -443,8 +460,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 |
|
| 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 |
|
| 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.711** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 |
|
| **Предложенная** | **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.782 | 0.864 | 47.4 | 0.79 | 5.90 |
|
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.783 | 0.864 | 47.2 | 0.79 | 5.90 |
|
||||||
|
|
||||||
**Клубы** (потолок 0.6308)
|
**Клубы** (потолок 0.6308)
|
||||||
|
|
||||||
@@ -452,8 +469,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 |
|
| 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 |
|
| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 |
|
||||||
| **Предложенная** | **0.6102** | 0.2336 | **0.644** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
|
| **Предложенная** | **0.6101** | 0.2336 | **0.645** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
|
||||||
| Предложенная, без новых полей | 0.6074 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.534 | 103.3 | 0.38 | 6.41 |
|
| Предложенная, без новых полей | 0.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 |
|
||||||
|
|
||||||
**Рост** (потолок 0.6864)
|
**Рост** (потолок 0.6864)
|
||||||
|
|
||||||
@@ -461,8 +478,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 |
|
| 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 |
|
| 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.774** | **0.851** | **42.7** | **0.95** | 5.96 |
|
| **Предложенная** | **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.6 | 0.79 | 5.91 |
|
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.5 | 0.79 | 5.91 |
|
||||||
|
|
||||||
**Шум** (потолок 0.6894)
|
**Шум** (потолок 0.6894)
|
||||||
|
|
||||||
@@ -470,16 +487,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 |
|
| 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 |
|
| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 |
|
||||||
| Предложенная | 0.6727 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.6 | 0.81 | 5.91 |
|
| Предложенная | 0.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 |
|
||||||
| Предложенная, без новых полей | **0.6743** | **0.2070** | **0.919** | **0.667** | **0.775** | **0.856** | 47.4 | 0.78 | 5.88 |
|
| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 |
|
||||||
|
|
||||||
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
||||||
|
|
||||||
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.37 ± 0.07 п.п. |
|
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. |
|
||||||
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.57 ± 0.10 п.п. |
|
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. |
|
||||||
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.07 п.п. | +0.37 ± 0.06 п.п. |
|
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. |
|
||||||
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
||||||
|
|
||||||
Выводы:
|
Выводы:
|
||||||
@@ -490,7 +507,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
||||||
отрываются от середняков.
|
отрываются от середняков.
|
||||||
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
||||||
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.644): чемпион слабой группы
|
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы
|
||||||
у неё стоит рядом с чемпионом сильной.
|
у неё стоит рядом с чемпионом сильной.
|
||||||
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
||||||
цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях
|
цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях
|
||||||
@@ -506,6 +523,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|
|
||||||
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
||||||
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
||||||
|
Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не
|
||||||
|
переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3).
|
||||||
|
|
||||||
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
||||||
Выгоден высокий K новичка и низкий K ветерана.
|
Выгоден высокий K новичка и низкий K ветерана.
|
||||||
@@ -670,8 +689,11 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
завершённых партий по порядку (`played_at`, `finished_at`, `id`), а не агрегатом SQL.
|
завершённых партий по порядку (`played_at`, `finished_at`, `id`), а не агрегатом SQL.
|
||||||
Данных мало: сотни партий, микросекунды на пару. Существующий принцип «считается вживую»
|
Данных мало: сотни партий, микросекунды на пару. Существующий принцип «считается вживую»
|
||||||
сохраняется, кэш можно ввести позже с инвалидацией по уже существующим SSE-событиям.
|
сохраняется, кэш можно ввести позже с инвалидацией по уже существующим SSE-событиям.
|
||||||
- **Две цепочки:** общий рейтинг — по всем партиям приложения, групповой — по партиям
|
- **Одна цепочка** (решение владельца 2026-09-15, #80): рейтинг у игрока один — по всем
|
||||||
группы. Это разные числа, как и сейчас.
|
партиям приложения, K — по всем его партиям. Отдельного группового рейтинга нет:
|
||||||
|
на странице группы игры, победы, винрейт и среднее место считаются по партиям группы,
|
||||||
|
а рейтинг и статус «Новичок» — общие. Первая версия реализации (#23) держала две
|
||||||
|
цепочки, общую и групповую, — это оказалось неинтуитивно (раздел 9).
|
||||||
- Правка или удаление прошлой партии автоматически меняет всё после неё: при пересчёте
|
- Правка или удаление прошлой партии автоматически меняет всё после неё: при пересчёте
|
||||||
с нуля отдельной логики не нужно.
|
с нуля отдельной логики не нужно.
|
||||||
- Эталон — `rate_match` в `simulate.py`. Примеры из раздела 6 стоит перенести в тесты
|
- Эталон — `rate_match` в `simulate.py`. Примеры из раздела 6 стоит перенести в тесты
|
||||||
@@ -728,6 +750,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
| 4 | Ввод миров на конец партии | **Оставить** | без изменений: `w_w = 0.5` |
|
| 4 | Ввод миров на конец партии | **Оставить** | без изменений: `w_w = 0.5` |
|
||||||
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
||||||
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
| 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, «Калибровка»).
|
после внедрения #23 (раздел 8, «Калибровка»).
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class Params:
|
|||||||
m_min: float = 0.5
|
m_min: float = 0.5
|
||||||
m_max: float = 2.0
|
m_max: float = 2.0
|
||||||
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
||||||
|
# Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму
|
||||||
|
# (решение владельца, #91). У систем для сравнения — как было.
|
||||||
|
skip_eliminated_pairs: bool = False
|
||||||
|
|
||||||
def closeness_for(self, reason: str | None) -> float:
|
def closeness_for(self, reason: str | None) -> float:
|
||||||
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
||||||
@@ -181,6 +184,8 @@ def rate_match(
|
|||||||
k = {s.player: k_factor(games.get(s.player, 0), p) 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}
|
delta = {s.player: 0.0 for s in m.seats}
|
||||||
for a, b in combinations(m.seats, 2):
|
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:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
@@ -550,6 +555,7 @@ PROPOSED = Params(
|
|||||||
w_obj=0.5,
|
w_obj=0.5,
|
||||||
w_worlds=0.5,
|
w_worlds=0.5,
|
||||||
closeness=CLOSENESS,
|
closeness=CLOSENESS,
|
||||||
|
skip_eliminated_pairs=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -594,7 +600,7 @@ def examples() -> list[tuple[str, str, dict, dict, Match]]:
|
|||||||
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
||||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||||
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
||||||
("5", "Стол на 4: ничья выбывших, раунд 7",
|
("5", "Стол на 4: двое выбывших, раунд 7",
|
||||||
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
||||||
Match(
|
Match(
|
||||||
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
||||||
@@ -625,7 +631,7 @@ EXPECTED: dict[str, dict[str, float]] = {
|
|||||||
"4c": {"A": 4.8, "B": -4.8},
|
"4c": {"A": 4.8, "B": -4.8},
|
||||||
"4d": {"A": 3.4, "B": -3.4},
|
"4d": {"A": 3.4, "B": -3.4},
|
||||||
"4e": {"A": 16.0, "B": -16.0},
|
"4e": {"A": 16.0, "B": -16.0},
|
||||||
"5": {"A": 9.27, "B": 5.85, "C": -7.78, "D": -7.35},
|
"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},
|
"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},
|
"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},
|
"7": {"A": 32.0, "B": -8.0},
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import { queryClient } from "./app/queryClient";
|
import { queryClient } from "./app/queryClient";
|
||||||
import { router } from "./app/router";
|
import { router } from "./app/router";
|
||||||
|
import { Spinner } from "./components/Spinner";
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
|
import { setAppTzOffsetHours } from "./domain/format";
|
||||||
|
import { useAuthConfig } from "./hooks/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
|
||||||
|
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
|
||||||
|
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
|
||||||
|
*/
|
||||||
|
function AppTimeZone({ children }: { children: ReactNode }) {
|
||||||
|
const { data, isPending } = useAuthConfig();
|
||||||
|
if (data) setAppTzOffsetHours(data.tz_offset_hours);
|
||||||
|
if (isPending) return <Spinner />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<AppTimeZone>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</AppTimeZone>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const qk = {
|
export const qk = {
|
||||||
me: ["me"] as const,
|
me: ["me"] as const,
|
||||||
adminMe: ["adminMe"] as const,
|
adminMe: ["adminMe"] as const,
|
||||||
@@ -19,19 +21,22 @@ export const qk = {
|
|||||||
authConfig: ["authConfig"] as const,
|
authConfig: ["authConfig"] as const,
|
||||||
invitations: ["invitations"] as const,
|
invitations: ["invitations"] as const,
|
||||||
notifications: ["notifications"] as const,
|
notifications: ["notifications"] as const,
|
||||||
|
announcements: ["announcements"] as const,
|
||||||
devUsers: ["devUsers"] as const,
|
devUsers: ["devUsers"] as const,
|
||||||
adminUsers: ["adminUsers"] as const,
|
adminUsers: ["adminUsers"] as const,
|
||||||
adminGroups: ["adminGroups"] as const,
|
adminGroups: ["adminGroups"] as const,
|
||||||
adminMatches: ["adminMatches"] as const,
|
adminMatches: ["adminMatches"] as const,
|
||||||
adminLogs: ["adminLogs"] as const,
|
adminLogs: ["adminLogs"] as const,
|
||||||
adminAchievements: ["adminAchievements"] as const,
|
adminAchievements: ["adminAchievements"] as const,
|
||||||
|
adminAnnouncements: ["adminAnnouncements"] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
|
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
|
||||||
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
|
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
|
||||||
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
|
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
|
||||||
* в этом файле тихо разойдётся с местами, где он написан строкой.
|
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
|
||||||
|
* написан строкой.
|
||||||
*/
|
*/
|
||||||
export const matchAffectedKeys = [
|
export const matchAffectedKeys = [
|
||||||
qk.home,
|
qk.home,
|
||||||
@@ -40,3 +45,15 @@ export const matchAffectedKeys = [
|
|||||||
["userMatches"],
|
["userMatches"],
|
||||||
["publicProfile"],
|
["publicProfile"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все рейтинговые витрины, включая статистику любой группы: рейтинг игроков в ней
|
||||||
|
* общий, так что его двигает и партия другой группы (#88). Перезапрашиваются только
|
||||||
|
* открытые на экране запросы, остальные лишь помечаются устаревшими.
|
||||||
|
*/
|
||||||
|
export function invalidateRatingViews(qc: QueryClient) {
|
||||||
|
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||||
|
qc.invalidateQueries({
|
||||||
|
predicate: (q) => q.queryKey[0] === "group" && q.queryKey[2] === "stats",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+404
@@ -842,6 +842,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/announcements/pending": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Pending */
|
||||||
|
get: operations["pending_api_announcements_pending_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/announcements/{announcement_id}/ack": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Acknowledge */
|
||||||
|
post: operations["acknowledge_api_announcements__announcement_id__ack_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/auth/login": {
|
"/api/admin/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1140,6 +1174,62 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/announcements": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Announcements */
|
||||||
|
get: operations["list_announcements_api_admin_announcements_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Announcement */
|
||||||
|
post: operations["create_announcement_api_admin_announcements_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/admin/announcements/{announcement_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/** Update Announcement */
|
||||||
|
put: operations["update_announcement_api_admin_announcements__announcement_id__put"];
|
||||||
|
post?: never;
|
||||||
|
/** Delete Announcement */
|
||||||
|
delete: operations["delete_announcement_api_admin_announcements__announcement_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/admin/announcements/{announcement_id}/stop": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Stop Announcement
|
||||||
|
* @description «Снять с показа»: период идущего объявления заканчивается сейчас.
|
||||||
|
*/
|
||||||
|
post: operations["stop_announcement_api_admin_announcements__announcement_id__stop_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/audit-logs": {
|
"/api/admin/audit-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1277,6 +1367,36 @@ export interface components {
|
|||||||
/** Group Id */
|
/** Group Id */
|
||||||
group_id?: number | null;
|
group_id?: number | null;
|
||||||
};
|
};
|
||||||
|
/** AdminAnnouncementRead */
|
||||||
|
AdminAnnouncementRead: {
|
||||||
|
/** Id */
|
||||||
|
id: number;
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Body Html */
|
||||||
|
body_html: string;
|
||||||
|
/** Starts At */
|
||||||
|
starts_at: string;
|
||||||
|
/** Ends At */
|
||||||
|
ends_at: string;
|
||||||
|
/** Show To New Players */
|
||||||
|
show_to_new_players: boolean;
|
||||||
|
/** Revision */
|
||||||
|
revision: number;
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status: "planned" | "live" | "finished";
|
||||||
|
/** Seen Count */
|
||||||
|
seen_count: number;
|
||||||
|
/** Audience Count */
|
||||||
|
audience_count: number;
|
||||||
|
/** Created At */
|
||||||
|
created_at: string;
|
||||||
|
/** Updated At */
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
/** AdminGroupRead */
|
/** AdminGroupRead */
|
||||||
AdminGroupRead: {
|
AdminGroupRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1361,6 +1481,76 @@ export interface components {
|
|||||||
/** Is Active */
|
/** Is Active */
|
||||||
is_active?: boolean | null;
|
is_active?: boolean | null;
|
||||||
};
|
};
|
||||||
|
/** AnnouncementAck */
|
||||||
|
AnnouncementAck: {
|
||||||
|
/** Revision */
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
/** AnnouncementRead */
|
||||||
|
AnnouncementRead: {
|
||||||
|
/** Id */
|
||||||
|
id: number;
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Body Html */
|
||||||
|
body_html: string;
|
||||||
|
/** Revision */
|
||||||
|
revision: number;
|
||||||
|
/**
|
||||||
|
* Updated
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
updated: boolean;
|
||||||
|
};
|
||||||
|
/** AnnouncementUpdate */
|
||||||
|
AnnouncementUpdate: {
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Body Html */
|
||||||
|
body_html: string;
|
||||||
|
/**
|
||||||
|
* Starts At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
starts_at: string;
|
||||||
|
/**
|
||||||
|
* Ends At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
ends_at: string;
|
||||||
|
/**
|
||||||
|
* Show To New Players
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
show_to_new_players: boolean;
|
||||||
|
/**
|
||||||
|
* Reshow
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
reshow: boolean;
|
||||||
|
};
|
||||||
|
/** AnnouncementWrite */
|
||||||
|
AnnouncementWrite: {
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Body Html */
|
||||||
|
body_html: string;
|
||||||
|
/**
|
||||||
|
* Starts At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
starts_at: string;
|
||||||
|
/**
|
||||||
|
* Ends At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
ends_at: string;
|
||||||
|
/**
|
||||||
|
* Show To New Players
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
show_to_new_players: boolean;
|
||||||
|
};
|
||||||
/** AttachmentRead */
|
/** AttachmentRead */
|
||||||
AttachmentRead: {
|
AttachmentRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1420,6 +1610,8 @@ export interface components {
|
|||||||
methods: string[];
|
methods: string[];
|
||||||
/** Telegram Bot Username */
|
/** Telegram Bot Username */
|
||||||
telegram_bot_username?: string | null;
|
telegram_bot_username?: string | null;
|
||||||
|
/** Tz Offset Hours */
|
||||||
|
tz_offset_hours: number;
|
||||||
};
|
};
|
||||||
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
||||||
Body_add_attachment_api_matches__match_id__attachments_post: {
|
Body_add_attachment_api_matches__match_id__attachments_post: {
|
||||||
@@ -1724,6 +1916,11 @@ export interface components {
|
|||||||
rank?: number | null;
|
rank?: number | null;
|
||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
|
/**
|
||||||
|
* Rating Confirmed
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
rating_confirmed: boolean;
|
||||||
};
|
};
|
||||||
/** MatchCreate */
|
/** MatchCreate */
|
||||||
MatchCreate: {
|
MatchCreate: {
|
||||||
@@ -1888,6 +2085,8 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
participants: components["schemas"]["MatchListParticipant"][];
|
participants: components["schemas"]["MatchListParticipant"][];
|
||||||
|
/** Rating Delta */
|
||||||
|
rating_delta?: number | null;
|
||||||
};
|
};
|
||||||
/** MatchListParticipant */
|
/** MatchListParticipant */
|
||||||
MatchListParticipant: {
|
MatchListParticipant: {
|
||||||
@@ -3973,6 +4172,61 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
pending_api_announcements_pending_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AnnouncementRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
acknowledge_api_announcements__announcement_id__ack_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
announcement_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AnnouncementAck"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["OkResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
admin_login_api_admin_auth_login_post: {
|
admin_login_api_admin_auth_login_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4669,6 +4923,156 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_announcements_api_admin_announcements_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminAnnouncementRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_announcement_api_admin_announcements_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AnnouncementWrite"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminAnnouncementRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update_announcement_api_admin_announcements__announcement_id__put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
announcement_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AnnouncementUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminAnnouncementRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_announcement_api_admin_announcements__announcement_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
announcement_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["OkResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
stop_announcement_api_admin_announcements__announcement_id__stop_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
announcement_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminAnnouncementRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
audit_logs_api_admin_audit_logs_get: {
|
audit_logs_api_admin_audit_logs_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { OverallStatsPage } from "../pages/OverallStatsPage";
|
|||||||
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
||||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||||
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
|
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
|
||||||
|
import { AdminAnnouncementsPage } from "../pages/admin/AdminAnnouncementsPage";
|
||||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||||
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
||||||
@@ -70,6 +71,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "factions", element: <AdminFactionsPage /> },
|
{ path: "factions", element: <AdminFactionsPage /> },
|
||||||
{ path: "achievements", element: <AdminAchievementsPage /> },
|
{ path: "achievements", element: <AdminAchievementsPage /> },
|
||||||
{ path: "logs", element: <AdminLogsPage /> },
|
{ path: "logs", element: <AdminLogsPage /> },
|
||||||
|
{ path: "announcements", element: <AdminAnnouncementsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "*", element: <Navigate to="/" replace /> },
|
{ path: "*", element: <Navigate to="/" replace /> },
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useAckAnnouncement, usePendingAnnouncements } from "../hooks/announcements";
|
||||||
|
import { AnnouncementWindow } from "./AnnouncementWindow";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очередь объявлений администрации поверх любой страницы: по одному, от старого
|
||||||
|
* к новому, следующее — после «Понятно» на предыдущем. AppShell монтирует её только
|
||||||
|
* когда пароль задан: обязательное окно пароля всегда первое.
|
||||||
|
*/
|
||||||
|
export function AnnouncementDialog() {
|
||||||
|
const { data } = usePendingAnnouncements(true);
|
||||||
|
const ack = useAckAnnouncement();
|
||||||
|
// Сколько уже закрыто в этой очереди — для точек «2 из 3». Закрытые сразу уходят
|
||||||
|
// из данных запроса, так что без счётчика точки показывали бы только остаток.
|
||||||
|
const [closed, setClosed] = useState(0);
|
||||||
|
const items = data ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length === 0) setClosed(0);
|
||||||
|
}, [items.length]);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
const current = items[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnnouncementWindow
|
||||||
|
key={current.id}
|
||||||
|
title={current.title}
|
||||||
|
bodyHtml={current.body_html}
|
||||||
|
updated={current.updated}
|
||||||
|
index={closed}
|
||||||
|
total={closed + items.length}
|
||||||
|
onOk={() => {
|
||||||
|
setClosed((c) => c + 1);
|
||||||
|
ack.mutate({ id: current.id, revision: current.revision });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Окно объявления администрации (вариант E из #84): заголовок — в плашке-шапке, ниже текст,
|
||||||
|
* точки очереди и «Понятно». Крестика нет намеренно: закрытие — только осознанное.
|
||||||
|
*
|
||||||
|
* bodyHtml у игрока — HTML, очищенный сервером по белому списку (announcement_service),
|
||||||
|
* поэтому вставляется как есть. В предпросмотре админки — содержимое его же редактора.
|
||||||
|
*/
|
||||||
|
export function AnnouncementWindow({
|
||||||
|
title,
|
||||||
|
bodyHtml,
|
||||||
|
updated = false,
|
||||||
|
index = 0,
|
||||||
|
total = 1,
|
||||||
|
onOk,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
updated?: boolean;
|
||||||
|
index?: number;
|
||||||
|
total?: number;
|
||||||
|
onOk: () => void;
|
||||||
|
}) {
|
||||||
|
const titleId = useId();
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal ann-modal" role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
||||||
|
<div className="ann-ribbon">
|
||||||
|
<div className="ann-ribbon-title" id={titleId}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{updated && <div className="ann-updated">обновлено</div>}
|
||||||
|
</div>
|
||||||
|
<div className="ann-body" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||||
|
<div className="modal-actions ann-actions">
|
||||||
|
<div className="ann-dots" aria-hidden="true">
|
||||||
|
{total > 1 &&
|
||||||
|
Array.from({ length: total }, (_, k) => (
|
||||||
|
<i key={k} className={k <= index ? "on" : undefined} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={onOk} autoFocus>
|
||||||
|
Понятно
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useServerEvents } from "../hooks/useServerEvents";
|
import { useServerEvents } from "../hooks/useServerEvents";
|
||||||
|
import { AnnouncementDialog } from "./AnnouncementDialog";
|
||||||
import { BottomBar } from "./BottomBar";
|
import { BottomBar } from "./BottomBar";
|
||||||
import { NotificationBell } from "./NotificationBell";
|
import { NotificationBell } from "./NotificationBell";
|
||||||
import { NotificationToaster } from "./NotificationToaster";
|
import { NotificationToaster } from "./NotificationToaster";
|
||||||
@@ -55,6 +56,8 @@ export function AppShell() {
|
|||||||
|
|
||||||
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
|
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
|
||||||
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
|
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
|
||||||
|
{/* Объявления администрации — только после окна пароля, на любой странице. */}
|
||||||
|
{me?.has_password && <AnnouncementDialog />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,14 @@ function Row({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только
|
{/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только
|
||||||
подтверждённый рейтинг. */}
|
подтверждённый рейтинг. Рейтинг общий, поэтому подтверждённость приходит
|
||||||
<div className={"lb-score" + (provisional || entry.score == null ? " provisional" : "")}>
|
флагом: в группе «Ещё не играли» бывают и новички, и опытные игроки. */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"lb-score" +
|
||||||
|
(provisional || entry.score == null || !entry.rating_confirmed ? " provisional" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
{entry.score ?? "—"}
|
{entry.score ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatDate, formatDuration } from "../domain/format";
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
import { MatchListView } from "./MatchList";
|
import { MatchListView } from "./MatchList";
|
||||||
|
import { RatingDelta } from "./RatingDelta";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
||||||
@@ -53,10 +54,11 @@ export function MatchHistory({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="card row-between"
|
className="card"
|
||||||
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||||
onClick={() => navigate(`/match/${m.id}`)}
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
>
|
>
|
||||||
|
<div className="row-between">
|
||||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
@@ -76,6 +78,9 @@ export function MatchHistory({
|
|||||||
<span className="muted small" style={{ flexShrink: 0 }}>
|
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||||
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
|
||||||
|
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatDate, formatDuration } from "../domain/format";
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
import { winReasonLabel } from "../domain/winReasons";
|
import { winReasonLabel } from "../domain/winReasons";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
|
import { RatingDelta } from "./RatingDelta";
|
||||||
|
|
||||||
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
||||||
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
||||||
* отфильтрованной по status="finished". */
|
* отфильтрованной по status="finished". В истории профиля у партии есть rating_delta
|
||||||
|
* владельца — она идёт последней строкой; у списка группы её нет. */
|
||||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||||
@@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
||||||
|
|
||||||
export interface PlacePlayer {
|
export interface PlacePlayer {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -241,19 +242,11 @@ export function PlaceEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
||||||
const raw = e.target.value.trim();
|
|
||||||
const num = Number(raw);
|
|
||||||
onCount?.(
|
|
||||||
userId,
|
|
||||||
field,
|
|
||||||
raw === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num))),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { formatRatingDelta } from "../domain/format";
|
||||||
|
|
||||||
|
/** Строка карточки истории: сколько общего рейтинга владелец профиля получил или
|
||||||
|
* потерял за партию. Рост — зелёным, падение — красным, ноль — без цвета. */
|
||||||
|
export function RatingDelta({ delta }: { delta: number }) {
|
||||||
|
const text = formatRatingDelta(delta);
|
||||||
|
const tone = text.startsWith("+") ? "rating-up" : text.startsWith("−") ? "rating-down" : undefined;
|
||||||
|
return (
|
||||||
|
<div className="small rating-delta">
|
||||||
|
Рейтинг: <b className={tone}>{text}</b>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
|
// Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
|
||||||
const APP_TZ_OFFSET_MIN = 3 * 60;
|
// Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
|
||||||
|
// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
|
||||||
|
// 3 ч (МСК) — запасное значение, если конфиг недоступен.
|
||||||
|
let appTzOffsetMin = 3 * 60;
|
||||||
|
|
||||||
|
export function setAppTzOffsetHours(hours: number): void {
|
||||||
|
appTzOffsetMin = hours * 60;
|
||||||
|
}
|
||||||
|
|
||||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
|
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
|
||||||
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
|
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
|
||||||
function shifted(iso: string): Date {
|
function shifted(iso: string): Date {
|
||||||
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
||||||
@@ -18,6 +25,14 @@ export function plural(n: number, one: string, few: string, many: string): strin
|
|||||||
return many;
|
return many;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Изменение рейтинга за партию: всегда со знаком и одним знаком после запятой
|
||||||
|
// («+12.3», «−4.1», «0.0»); минус — типографский, как в справке.
|
||||||
|
export function formatRatingDelta(delta: number): string {
|
||||||
|
const abs = Math.abs(delta).toFixed(1);
|
||||||
|
if (abs === "0.0") return "0.0";
|
||||||
|
return `${delta > 0 ? "+" : "−"}${abs}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDuration(minutes: number | null | undefined): string {
|
export function formatDuration(minutes: number | null | undefined): string {
|
||||||
if (minutes == null) return "—";
|
if (minutes == null) return "—";
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
@@ -42,6 +57,22 @@ export function formatDate(iso: string | null | undefined): string {
|
|||||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
|
||||||
|
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
|
||||||
|
export function toAppLocalInput(iso: string): string {
|
||||||
|
const d = shifted(iso);
|
||||||
|
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
|
||||||
|
// null — поле пустое.
|
||||||
|
export function fromAppLocalInput(value: string): string | null {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
|
||||||
|
if (!m) return null;
|
||||||
|
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDateTime(iso: string | null | undefined): string {
|
export function formatDateTime(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = shifted(iso);
|
const d = shifted(iso);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме API (Count). */
|
||||||
|
export const MAX_COUNT = 99;
|
||||||
|
|
||||||
|
/** Поле «цели»/«миры» формы: пустое — не указано (null), иначе целое 0..MAX_COUNT. */
|
||||||
|
export function parseCount(raw: string): number | null {
|
||||||
|
const text = raw.trim();
|
||||||
|
const num = Number(text);
|
||||||
|
if (text === "" || !Number.isFinite(num)) return null;
|
||||||
|
return Math.min(MAX_COUNT, Math.max(0, Math.trunc(num)));
|
||||||
|
}
|
||||||
@@ -34,3 +34,7 @@ export type MatchUpdate = S["MatchUpdate"];
|
|||||||
export type MatchCreate = S["MatchCreate"];
|
export type MatchCreate = S["MatchCreate"];
|
||||||
export type MatchFinish = S["MatchFinish"];
|
export type MatchFinish = S["MatchFinish"];
|
||||||
export type MatchFinishDraftData = S["MatchFinishDraftData"];
|
export type MatchFinishDraftData = S["MatchFinishDraftData"];
|
||||||
|
export type AnnouncementRead = S["AnnouncementRead"];
|
||||||
|
export type AdminAnnouncementRead = S["AdminAnnouncementRead"];
|
||||||
|
export type AnnouncementWrite = S["AnnouncementWrite"];
|
||||||
|
export type AnnouncementUpdate = S["AnnouncementUpdate"];
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { api, authProbeRetry, unwrap } from "../api/client";
|
import { api, authProbeRetry, unwrap } from "../api/client";
|
||||||
import { qk } from "../api/queryKeys";
|
import { qk } from "../api/queryKeys";
|
||||||
import type { AdminMe, MatchUpdate } from "../domain/types";
|
import type {
|
||||||
|
AdminMe,
|
||||||
|
AnnouncementUpdate,
|
||||||
|
AnnouncementWrite,
|
||||||
|
MatchUpdate,
|
||||||
|
} from "../domain/types";
|
||||||
import { uploadFile } from "../lib/upload";
|
import { uploadFile } from "../lib/upload";
|
||||||
|
|
||||||
export function useAdminMe() {
|
export function useAdminMe() {
|
||||||
@@ -282,3 +287,57 @@ export function useUploadAchievementIcon() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Объявления ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAdminAnnouncements() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.adminAnnouncements,
|
||||||
|
queryFn: async () => unwrap(await api.GET("/api/admin/announcements")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAnnouncementMutation<A, R>(fn: (args: A) => Promise<R>) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: fn,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAnnouncements }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminCreateAnnouncement() {
|
||||||
|
return useAnnouncementMutation(async (body: AnnouncementWrite) =>
|
||||||
|
unwrap(await api.POST("/api/admin/announcements", { body })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminUpdateAnnouncement() {
|
||||||
|
return useAnnouncementMutation(async (args: { id: number; body: AnnouncementUpdate }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PUT("/api/admin/announcements/{announcement_id}", {
|
||||||
|
params: { path: { announcement_id: args.id } },
|
||||||
|
body: args.body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminStopAnnouncement() {
|
||||||
|
return useAnnouncementMutation(async (id: number) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST("/api/admin/announcements/{announcement_id}/stop", {
|
||||||
|
params: { path: { announcement_id: id } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminDeleteAnnouncement() {
|
||||||
|
return useAnnouncementMutation(async (id: number) =>
|
||||||
|
unwrap(
|
||||||
|
await api.DELETE("/api/admin/announcements/{announcement_id}", {
|
||||||
|
params: { path: { announcement_id: id } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api, unwrap } from "../api/client";
|
||||||
|
import { qk } from "../api/queryKeys";
|
||||||
|
import type { AnnouncementRead } from "../domain/types";
|
||||||
|
|
||||||
|
// Объявления администрации, которые игрок ещё не закрыл, — от старого к новому.
|
||||||
|
export function usePendingAnnouncements(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.announcements,
|
||||||
|
queryFn: async () => unwrap(await api.GET("/api/announcements/pending")),
|
||||||
|
enabled,
|
||||||
|
// Новое и изменённое объявление приходит SSE-событием, а вот наступление отложенного
|
||||||
|
// начала показа — не событие в данных. Его подхватываем периодическим перезапросом.
|
||||||
|
refetchInterval: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Понятно»: объявление помечается закрытым в той версии, которую игрок видел.
|
||||||
|
export function useAckAnnouncement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (a: { id: number; revision: number }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST("/api/announcements/{announcement_id}/ack", {
|
||||||
|
params: { path: { announcement_id: a.id } },
|
||||||
|
body: { revision: a.revision },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// Закрытое убираем из очереди сразу — следующее открывается, не дожидаясь сети.
|
||||||
|
onMutate: async (a) => {
|
||||||
|
await qc.cancelQueries({ queryKey: qk.announcements });
|
||||||
|
qc.setQueryData<AnnouncementRead[]>(qk.announcements, (old) =>
|
||||||
|
old?.filter((x) => x.id !== a.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Если отметка не записалась, объявление вернётся с перезапросом, а не пропадёт молча.
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: qk.announcements }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -138,6 +138,8 @@ export function useRemoveMember(groupId: number) {
|
|||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
|
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
|
||||||
|
// Список игроков группы показывает только текущий состав — удалённый из него уходит.
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groupStats(groupId) });
|
||||||
qc.invalidateQueries({ queryKey: qk.me });
|
qc.invalidateQueries({ queryKey: qk.me });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api, unwrap } from "../api/client";
|
import { api, unwrap } from "../api/client";
|
||||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||||
import type {
|
import type {
|
||||||
FactionRead,
|
FactionRead,
|
||||||
MatchCreate,
|
MatchCreate,
|
||||||
@@ -83,8 +83,7 @@ export function useFinishMatch() {
|
|||||||
onSuccess: (m) => {
|
onSuccess: (m) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
invalidateRatingViews(qc);
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,9 +102,8 @@ export function useUpdateMatch() {
|
|||||||
onSuccess: (m) => {
|
onSuccess: (m) => {
|
||||||
qc.setQueryData(qk.match(m.id), m);
|
qc.setQueryData(qk.match(m.id), m);
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
// Места изменились — значит изменились рейтинги, топ, истории и профили.
|
||||||
// Места изменились — значит изменились лидерборд, история и профили.
|
invalidateRatingViews(qc);
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||||
import { useMe } from "./auth";
|
|
||||||
|
|
||||||
interface ServerEvent {
|
interface ServerEvent {
|
||||||
type: "match" | "match_draft" | "group" | "invitations" | "notifications";
|
/** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
|
||||||
|
type:
|
||||||
|
| "match"
|
||||||
|
| "match_draft"
|
||||||
|
| "ratings"
|
||||||
|
| "group"
|
||||||
|
| "invitations"
|
||||||
|
| "notifications"
|
||||||
|
| "announcements";
|
||||||
match_id?: number;
|
match_id?: number;
|
||||||
group_id?: number;
|
group_id?: number;
|
||||||
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
|
|
||||||
participant_ids?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,11 +24,6 @@ interface ServerEvent {
|
|||||||
*/
|
*/
|
||||||
export function useServerEvents(enabled: boolean) {
|
export function useServerEvents(enabled: boolean) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data: me } = useMe();
|
|
||||||
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
|
|
||||||
// SSE-соединение каждый раз, когда профиль перезапрашивается.
|
|
||||||
const myId = useRef<number | null>(null);
|
|
||||||
myId.current = me?.id ?? null;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
@@ -40,6 +40,8 @@ export function useServerEvents(enabled: boolean) {
|
|||||||
qc.invalidateQueries({ queryKey: qk.invitations });
|
qc.invalidateQueries({ queryKey: qk.invitations });
|
||||||
} else if (ev.type === "notifications") {
|
} else if (ev.type === "notifications") {
|
||||||
qc.invalidateQueries({ queryKey: qk.notifications });
|
qc.invalidateQueries({ queryKey: qk.notifications });
|
||||||
|
} else if (ev.type === "announcements") {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.announcements });
|
||||||
} else if (ev.type === "match_draft") {
|
} else if (ev.type === "match_draft") {
|
||||||
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
|
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
|
||||||
// без лидерборда и историй — иначе группа захлебнётся перезапросами.
|
// без лидерборда и историй — иначе группа захлебнётся перезапросами.
|
||||||
@@ -48,26 +50,12 @@ export function useServerEvents(enabled: boolean) {
|
|||||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
|
||||||
}
|
|
||||||
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
|
||||||
qc.invalidateQueries({ queryKey: qk.home });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
|
||||||
if (ev.participant_ids) {
|
|
||||||
// Личные витрины — только у игравших: иначе каждая партия в группе
|
|
||||||
// заставляла бы всех остальных перезапрашивать свою историю.
|
|
||||||
for (const pid of ev.participant_ids) {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.userMatches(pid) });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.publicProfile(pid) });
|
|
||||||
}
|
|
||||||
if (myId.current != null && ev.participant_ids.includes(myId.current)) {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Событие от бэкенда без списка участников (вкладка открыта до обновления
|
|
||||||
// сервера) — ведём себя как раньше, широко.
|
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
}
|
}
|
||||||
|
// Рейтинг общий и считается по всей истории: партия двигает топ, историю
|
||||||
|
// и профили всех, кто играл после неё, и страницы других групп.
|
||||||
|
invalidateRatingViews(qc);
|
||||||
|
} else if (ev.type === "ratings") {
|
||||||
|
invalidateRatingViews(qc);
|
||||||
} else if (ev.type === "group") {
|
} else if (ev.type === "group") {
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export function HelpPage() {
|
|||||||
<p className="small" style={{ marginTop: 10 }}>
|
<p className="small" style={{ marginTop: 10 }}>
|
||||||
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
||||||
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
||||||
делят последнее место.
|
делят последнее место, но между собой не сравниваются: в этой партии все они
|
||||||
|
проиграли, так что пара двух выбывших рейтинг не двигает.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +94,9 @@ export function HelpPage() {
|
|||||||
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
||||||
</p>
|
</p>
|
||||||
<p className="small" style={{ marginBottom: 0 }}>
|
<p className="small" style={{ marginBottom: 0 }}>
|
||||||
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
|
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
|
||||||
|
и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются
|
||||||
|
между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,10 +138,12 @@ export function HelpPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Общий и групповой рейтинг</h3>
|
<h3>Один рейтинг на всё приложение</h3>
|
||||||
<p className="small" style={{ margin: 0 }}>
|
<p className="small" style={{ margin: 0 }}>
|
||||||
Общий рейтинг считается по всем партиям приложения, групповой — только по партиям
|
Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый
|
||||||
группы. Поэтому числа в общем топе и в топе группы различаются.
|
в общем топе, в профиле и на странице группы. На странице группы по партиям этой
|
||||||
|
группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус
|
||||||
|
«Новичок» там общие.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,24 @@ const countDict = (counts: Counts, field: CountField): Record<string, number> =>
|
|||||||
|
|
||||||
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
||||||
|
|
||||||
|
// Места по раскладке: блоки сверху вниз (competition ranking — ничья съедает следующие
|
||||||
|
// места), затем выбывшие с общим последним местом.
|
||||||
|
const placeRows = (blocks: number[][], eliminated: number[]) => {
|
||||||
|
let place = 1;
|
||||||
|
const survivors = blocks.flatMap((ids) => {
|
||||||
|
const rows = ids.map((uid) => ({ uid, place, eliminated: false }));
|
||||||
|
place += ids.length;
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
return [...survivors, ...eliminated.map((uid) => ({ uid, place, eliminated: true }))];
|
||||||
|
};
|
||||||
|
|
||||||
export function MatchDetailPage() {
|
export function MatchDetailPage() {
|
||||||
const { matchId } = useParams();
|
const { matchId } = useParams();
|
||||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||||
const parsed = matchId ? Number(matchId) : NaN;
|
const parsed = matchId ? Number(matchId) : NaN;
|
||||||
const id = Number.isInteger(parsed) ? parsed : null;
|
const id = Number.isInteger(parsed) ? parsed : null;
|
||||||
const { data: match, isLoading, refetch } = useMatch(id);
|
const { data: match, isLoading, error: loadError, refetch } = useMatch(id);
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||||
const finish = useFinishMatch();
|
const finish = useFinishMatch();
|
||||||
@@ -132,7 +144,19 @@ export function MatchDetailPage() {
|
|||||||
}, [incoming, participants, match?.status, me?.id]);
|
}, [incoming, participants, match?.status, me?.id]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
if (!match) {
|
||||||
|
// Из истории чужого профиля можно попасть в партию группы, где зритель не состоит:
|
||||||
|
// сервер отдаёт 403, и «не найдена» здесь вводила бы в заблуждение.
|
||||||
|
if (loadError instanceof ApiError && loadError.code === "NOT_GROUP_MEMBER") {
|
||||||
|
return (
|
||||||
|
<div className="muted">
|
||||||
|
Партия сыграна в группе, в которой вы не состоите, — открыть её могут только
|
||||||
|
участники группы.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="muted">Партия не найдена.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
|
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
|
||||||
const inProgress = match.status === "in_progress";
|
const inProgress = match.status === "in_progress";
|
||||||
@@ -181,21 +205,14 @@ export function MatchDetailPage() {
|
|||||||
|
|
||||||
const outcomeSeats = (): OutcomeSeat[] => {
|
const outcomeSeats = (): OutcomeSeat[] => {
|
||||||
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
||||||
const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({
|
return placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||||
userId: uid,
|
userId: uid,
|
||||||
nickname: byId.get(uid)?.nickname ?? "",
|
nickname: byId.get(uid)?.nickname ?? "",
|
||||||
place,
|
place,
|
||||||
eliminated,
|
eliminated,
|
||||||
objectives: finishCounts[uid]?.objectives ?? null,
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
||||||
});
|
}));
|
||||||
let place = 1;
|
|
||||||
const rows = finishBlocks.flatMap((ids) => {
|
|
||||||
const out = ids.map((uid) => seat(uid, place, false));
|
|
||||||
place += ids.length;
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
return [...rows, ...elim.map((uid) => seat(uid, place, true))];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const warnings = () =>
|
const warnings = () =>
|
||||||
@@ -206,35 +223,16 @@ export function MatchDetailPage() {
|
|||||||
maxRounds: match.max_rounds,
|
maxRounds: match.max_rounds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
|
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
|
||||||
const resultRows = () => {
|
const resultRows = () =>
|
||||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||||
const countsFor = (uid: number, eliminated: boolean) => ({
|
user_id: uid,
|
||||||
|
place: eliminated ? null : place,
|
||||||
|
eliminated,
|
||||||
|
comment: (finishComments[uid] ?? "").trim() || null,
|
||||||
objectives: finishCounts[uid]?.objectives ?? null,
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
// У выбывшего миров нет — сервер сам запишет 0.
|
|
||||||
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||||
});
|
|
||||||
let place = 1;
|
|
||||||
const survivors = finishBlocks.flatMap((ids) => {
|
|
||||||
const rows = ids.map((uid) => ({
|
|
||||||
user_id: uid,
|
|
||||||
place,
|
|
||||||
eliminated: false,
|
|
||||||
comment: commentOf(uid),
|
|
||||||
...countsFor(uid, false),
|
|
||||||
}));
|
}));
|
||||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
|
||||||
return rows;
|
|
||||||
});
|
|
||||||
const eliminated = elim.map((uid) => ({
|
|
||||||
user_id: uid,
|
|
||||||
place: null,
|
|
||||||
eliminated: true,
|
|
||||||
comment: commentOf(uid),
|
|
||||||
...countsFor(uid, true),
|
|
||||||
}));
|
|
||||||
return [...survivors, ...eliminated];
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitFinish = async () => {
|
const submitFinish = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { AnnouncementWindow } from "../../components/AnnouncementWindow";
|
||||||
|
import { ConfirmDialog } from "../../components/ConfirmDialog";
|
||||||
|
import { Spinner } from "../../components/Spinner";
|
||||||
|
import { Switch } from "../../components/Switch";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
import { formatDateTime, fromAppLocalInput, toAppLocalInput } from "../../domain/format";
|
||||||
|
import type { AdminAnnouncementRead } from "../../domain/types";
|
||||||
|
import {
|
||||||
|
useAdminAnnouncements,
|
||||||
|
useAdminCreateAnnouncement,
|
||||||
|
useAdminDeleteAnnouncement,
|
||||||
|
useAdminStopAnnouncement,
|
||||||
|
useAdminUpdateAnnouncement,
|
||||||
|
} from "../../hooks/admin";
|
||||||
|
import { RichTextEditor } from "./RichTextEditor";
|
||||||
|
|
||||||
|
// Те же пределы, что проверяет сервер (announcement_service).
|
||||||
|
const TITLE_MAX = 60;
|
||||||
|
const TEXT_MAX = 600;
|
||||||
|
const WEEK_MS = 7 * 24 * 3600_000;
|
||||||
|
|
||||||
|
const STATUS: Record<AdminAnnouncementRead["status"], [string, string]> = {
|
||||||
|
planned: ["Запланировано", "badge"],
|
||||||
|
live: ["Идёт", "badge badge-live"],
|
||||||
|
finished: ["Завершено", "badge muted"],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
id: number | null; // null — новое объявление
|
||||||
|
key: number; // смена key пересоздаёт редактор с новым начальным текстом
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
length: number;
|
||||||
|
starts: string; // datetime-local, МСК
|
||||||
|
ends: string;
|
||||||
|
showToNew: boolean;
|
||||||
|
reshow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftSeq = 0;
|
||||||
|
|
||||||
|
// Новое объявление: показ с текущей минуты на неделю. from — «Дублировать».
|
||||||
|
function newDraft(from?: AdminAnnouncementRead): Draft {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
key: ++draftSeq,
|
||||||
|
title: from?.title ?? "",
|
||||||
|
html: from?.body_html ?? "",
|
||||||
|
length: 0,
|
||||||
|
starts: toAppLocalInput(new Date(now).toISOString()),
|
||||||
|
ends: toAppLocalInput(new Date(now + WEEK_MS).toISOString()),
|
||||||
|
showToNew: from?.show_to_new_players ?? true,
|
||||||
|
reshow: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function editDraft(a: AdminAnnouncementRead): Draft {
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
key: ++draftSeq,
|
||||||
|
title: a.title,
|
||||||
|
html: a.body_html,
|
||||||
|
length: 0,
|
||||||
|
starts: toAppLocalInput(a.starts_at),
|
||||||
|
ends: toAppLocalInput(a.ends_at),
|
||||||
|
showToNew: a.show_to_new_players,
|
||||||
|
reshow: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAnnouncementsPage() {
|
||||||
|
const { data: items, isLoading } = useAdminAnnouncements();
|
||||||
|
const create = useAdminCreateAnnouncement();
|
||||||
|
const update = useAdminUpdateAnnouncement();
|
||||||
|
const stop = useAdminStopAnnouncement();
|
||||||
|
const remove = useAdminDeleteAnnouncement();
|
||||||
|
const toast = useToast();
|
||||||
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<Draft>(() => newDraft());
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [preview, setPreview] = useState(false);
|
||||||
|
const [toDelete, setToDelete] = useState<AdminAnnouncementRead | null>(null);
|
||||||
|
|
||||||
|
const patch = (p: Partial<Draft>) => setDraft((d) => ({ ...d, ...p }));
|
||||||
|
|
||||||
|
const openDraft = (d: Draft) => {
|
||||||
|
setDraft(d);
|
||||||
|
setError(null);
|
||||||
|
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (): { starts: string; ends: string } | string => {
|
||||||
|
if (!draft.title.trim()) return "Введите заголовок.";
|
||||||
|
if (draft.length === 0) return "Введите текст объявления.";
|
||||||
|
if (draft.length > TEXT_MAX) return `Текст длиннее ${TEXT_MAX} символов.`;
|
||||||
|
const starts = fromAppLocalInput(draft.starts);
|
||||||
|
const ends = fromAppLocalInput(draft.ends);
|
||||||
|
if (!starts || !ends) return "Укажите начало и конец показа.";
|
||||||
|
if (ends <= starts) return "Конец показа должен быть позже начала.";
|
||||||
|
return { starts, ends };
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const period = validate();
|
||||||
|
if (typeof period === "string") {
|
||||||
|
setError(period);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
const body = {
|
||||||
|
title: draft.title.trim(),
|
||||||
|
body_html: draft.html,
|
||||||
|
starts_at: period.starts,
|
||||||
|
ends_at: period.ends,
|
||||||
|
show_to_new_players: draft.showToNew,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (draft.id == null) {
|
||||||
|
const a = await create.mutateAsync(body);
|
||||||
|
toast.show(a.status === "planned" ? "Объявление запланировано" : "Объявление опубликовано");
|
||||||
|
} else {
|
||||||
|
await update.mutateAsync({ id: draft.id, body: { ...body, reshow: draft.reshow } });
|
||||||
|
toast.show(draft.reshow ? "Сохранено — игроки увидят обновление" : "Сохранено");
|
||||||
|
}
|
||||||
|
setDraft(newDraft());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doStop = async (a: AdminAnnouncementRead) => {
|
||||||
|
try {
|
||||||
|
await stop.mutateAsync(a.id);
|
||||||
|
toast.show("Снято с показа");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
const a = toDelete;
|
||||||
|
setToDelete(null);
|
||||||
|
if (!a) return;
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(a.id);
|
||||||
|
if (draft.id === a.id) setDraft(newDraft());
|
||||||
|
toast.show("Объявление удалено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saving = create.isPending || update.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card ann-form" ref={formRef}>
|
||||||
|
{draft.id != null && (
|
||||||
|
<div className="row-between" style={{ marginBottom: 8 }}>
|
||||||
|
<span className="muted small">Правка объявления</span>
|
||||||
|
<button className="btn btn-ghost small" onClick={() => openDraft(newDraft())}>
|
||||||
|
Отменить правку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="ann-title">
|
||||||
|
Заголовок
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ann-title"
|
||||||
|
maxLength={TITLE_MAX}
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => patch({ title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">Текст объявления</span>
|
||||||
|
<RichTextEditor
|
||||||
|
key={draft.key}
|
||||||
|
initialHtml={draft.html}
|
||||||
|
onChange={(html, length) => patch({ html, length })}
|
||||||
|
/>
|
||||||
|
<div className={"rte-counter" + (draft.length > TEXT_MAX ? " over" : "")}>
|
||||||
|
{draft.length} / {TEXT_MAX}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ alignItems: "flex-start" }}>
|
||||||
|
<div className="field" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className="label" htmlFor="ann-starts">
|
||||||
|
Показывать с (МСК)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ann-starts"
|
||||||
|
type="datetime-local"
|
||||||
|
value={draft.starts}
|
||||||
|
onChange={(e) => patch({ starts: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className="label" htmlFor="ann-ends">
|
||||||
|
по (МСК)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ann-ends"
|
||||||
|
type="datetime-local"
|
||||||
|
value={draft.ends}
|
||||||
|
onChange={(e) => patch({ ends: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="row-between">
|
||||||
|
<span>Показывать новым игрокам</span>
|
||||||
|
<Switch checked={draft.showToNew} onChange={(v) => patch({ showToNew: v })} />
|
||||||
|
</label>
|
||||||
|
<p className="muted small" style={{ margin: "4px 0 12px" }}>
|
||||||
|
Выключите, если объявление ни к чему тем, кто зарегистрируется после начала показа.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{draft.id != null && (
|
||||||
|
<>
|
||||||
|
<label className="row-between">
|
||||||
|
<span>Показать заново тем, кто уже закрыл</span>
|
||||||
|
<Switch checked={draft.reshow} onChange={(v) => patch({ reshow: v })} />
|
||||||
|
</label>
|
||||||
|
<p className="muted small" style={{ margin: "4px 0 12px" }}>
|
||||||
|
Они увидят объявление ещё раз — с красной пометкой «обновлено» под заголовком.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-ghost" onClick={() => setPreview(true)}>
|
||||||
|
Предпросмотр
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" style={{ flex: 1 }} disabled={saving} onClick={save}>
|
||||||
|
{draft.id == null ? "Опубликовать" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Созданные объявления</h3>
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : !items?.length ? (
|
||||||
|
<p className="muted small">Объявлений пока нет.</p>
|
||||||
|
) : (
|
||||||
|
<div className="stack">
|
||||||
|
{items.map((a) => {
|
||||||
|
const [label, badge] = STATUS[a.status];
|
||||||
|
const meta = [
|
||||||
|
`${formatDateTime(a.starts_at)} — ${formatDateTime(a.ends_at)}`,
|
||||||
|
`закрыли ${a.seen_count} из ${a.audience_count}`,
|
||||||
|
...(a.show_to_new_players ? [] : ["без новых игроков"]),
|
||||||
|
...(a.revision > 1 ? [`версия ${a.revision}`] : []),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="card" style={{ margin: 0 }}>
|
||||||
|
<div className="row-between" style={{ alignItems: "flex-start" }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, overflowWrap: "anywhere" }}>{a.title}</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 2 }}>
|
||||||
|
{meta.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={badge}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ marginTop: 10, flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<button className="btn btn-ghost small" onClick={() => openDraft(editDraft(a))}>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
{a.status === "live" ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost small"
|
||||||
|
disabled={stop.isPending}
|
||||||
|
onClick={() => doStop(a)}
|
||||||
|
>
|
||||||
|
Снять с показа
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-ghost small" onClick={() => openDraft(newDraft(a))}>
|
||||||
|
Дублировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-danger small" onClick={() => setToDelete(a)}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<AnnouncementWindow
|
||||||
|
title={draft.title.trim() || "Без заголовка"}
|
||||||
|
bodyHtml={draft.html}
|
||||||
|
onOk={() => setPreview(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toDelete != null}
|
||||||
|
title="Удалить объявление?"
|
||||||
|
message={
|
||||||
|
toDelete
|
||||||
|
? `«${toDelete.title}» пропадёт у всех, кто его ещё не закрыл, а отметки о прочтении удалятся.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
danger
|
||||||
|
onConfirm={doDelete}
|
||||||
|
onCancel={() => setToDelete(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ export function AdminLayout() {
|
|||||||
<NavLink to="/admin/logs" className={tab}>
|
<NavLink to="/admin/logs" className={tab}>
|
||||||
Логи
|
Логи
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/admin/announcements" className={tab}>
|
||||||
|
Объявления
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<main className="app-main" style={{ paddingBottom: 24 }}>
|
<main className="app-main" style={{ paddingBottom: 24 }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { formatDateTime } from "../../domain/format";
|
|||||||
import { useAdminLogs } from "../../hooks/admin";
|
import { useAdminLogs } from "../../hooks/admin";
|
||||||
|
|
||||||
const ACTIONS = ["", "login", "create", "update", "delete"];
|
const ACTIONS = ["", "login", "create", "update", "delete"];
|
||||||
const ENTITIES = ["", "user", "group", "group_member", "match", "admin"];
|
const ENTITIES = ["", "user", "group", "group_member", "match", "admin", "announcement"];
|
||||||
|
|
||||||
export function AdminLogsPage() {
|
export function AdminLogsPage() {
|
||||||
const [action, setAction] = useState("");
|
const [action, setAction] = useState("");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MatchMedia } from "../../components/MatchMedia";
|
|||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { Switch } from "../../components/Switch";
|
import { Switch } from "../../components/Switch";
|
||||||
import { finishWarnings } from "../../domain/finishWarnings";
|
import { finishWarnings } from "../../domain/finishWarnings";
|
||||||
|
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
|
||||||
import {
|
import {
|
||||||
LAST_STANDING,
|
LAST_STANDING,
|
||||||
reasonForSurvivors,
|
reasonForSurvivors,
|
||||||
@@ -34,12 +35,6 @@ interface Row {
|
|||||||
worlds: number | null;
|
worlds: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пустое поле — «не указано» (null); иначе целое 0..99.
|
|
||||||
const parseCount = (raw: string): number | null => {
|
|
||||||
const num = Number(raw.trim());
|
|
||||||
return raw.trim() === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||||
const { data: match, isLoading } = useAdminMatch(matchId);
|
const { data: match, isLoading } = useAdminMatch(matchId);
|
||||||
const { data: factions } = useAdminFactions();
|
const { data: factions } = useAdminFactions();
|
||||||
@@ -218,7 +213,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="цели"
|
placeholder="цели"
|
||||||
title="Цели на конец партии"
|
title="Цели на конец партии"
|
||||||
value={r.objectives ?? ""}
|
value={r.objectives ?? ""}
|
||||||
@@ -228,7 +223,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="миры"
|
placeholder="миры"
|
||||||
title="Миры на конец партии"
|
title="Миры на конец партии"
|
||||||
disabled={r.eliminated}
|
disabled={r.eliminated}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
|
||||||
|
|
||||||
|
const escapeHtml = (s: string) =>
|
||||||
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
// Кнопка панели. mousedown гасим, чтобы фокус и выделение остались в поле текста.
|
||||||
|
function Tool({
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"rte-tool" + (className ? ` ${className}` : "")}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Редактор текста объявления (вариант R1 из #84): поле contenteditable и панель —
|
||||||
|
* жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления. Набор узкий
|
||||||
|
* намеренно: сервер всё равно оставит только b / em / mark / p / br
|
||||||
|
* (announcement_service.sanitize_body). Вставка и перетаскивание — только простым текстом.
|
||||||
|
*
|
||||||
|
* Поле неуправляемое: начальный HTML ставится при монтировании (для другого объявления
|
||||||
|
* родитель пересоздаёт редактор через key), дальше каждое изменение уходит в onChange
|
||||||
|
* вместе с длиной видимого текста в символах — так же её считает сервер.
|
||||||
|
*/
|
||||||
|
export function RichTextEditor({
|
||||||
|
initialHtml,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
initialHtml: string;
|
||||||
|
onChange: (html: string, length: number) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (el) onChange(el.innerHTML, [...(el.textContent ?? "").trim()].length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Только при монтировании: дальше содержимым владеет сам contenteditable.
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.innerHTML = initialHtml;
|
||||||
|
emit();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Выделение внутри поля — кнопки панели работают только с ним.
|
||||||
|
const rangeInEditor = (): Range | null => {
|
||||||
|
const sel = document.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) return null;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
return ref.current?.contains(range.commonAncestorContainer) ? range : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Курсор в конец поля, если в нём ещё не кликали (эмодзи до первого клика в текст).
|
||||||
|
const ensureCaret = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
if (rangeInEditor()) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false);
|
||||||
|
const sel = document.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = (cmd: "bold" | "italic") =>
|
||||||
|
run(() => {
|
||||||
|
if (rangeInEditor()) document.execCommand(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Акцент: выделенный текст целиком оборачивается в <mark> (золотой) или
|
||||||
|
// <mark class="red">. Оформление внутри выделения при этом снимается — для коротких
|
||||||
|
// акцентов это проще, чем вложенные теги.
|
||||||
|
const accent = (red: boolean) =>
|
||||||
|
run(() => {
|
||||||
|
const range = rangeInEditor();
|
||||||
|
if (!range || range.collapsed) return;
|
||||||
|
const inner = range.toString().split("\n").map(escapeHtml).join("<br>");
|
||||||
|
document.execCommand("insertHTML", false, `<mark${red ? ' class="red"' : ""}>${inner}</mark>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFormat = () =>
|
||||||
|
run(() => {
|
||||||
|
if (!rangeInEditor()) return;
|
||||||
|
document.execCommand("removeFormat");
|
||||||
|
// removeFormat не знает про <mark> — акценты в выделении снимаем сами.
|
||||||
|
const range = rangeInEditor();
|
||||||
|
ref.current?.querySelectorAll("mark").forEach((m) => {
|
||||||
|
if (range?.intersectsNode(m)) m.replaceWith(...Array.from(m.childNodes));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertEmoji = (emoji: string) =>
|
||||||
|
run(() => {
|
||||||
|
ensureCaret();
|
||||||
|
document.execCommand("insertText", false, emoji);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="rte-toolbar">
|
||||||
|
<Tool title="Жирный" onClick={() => format("bold")}>
|
||||||
|
<b>Ж</b>
|
||||||
|
</Tool>
|
||||||
|
<Tool title="Курсив" onClick={() => format("italic")}>
|
||||||
|
<em>К</em>
|
||||||
|
</Tool>
|
||||||
|
<Tool title="Золотой акцент" className="rte-gold" onClick={() => accent(false)}>
|
||||||
|
A
|
||||||
|
</Tool>
|
||||||
|
<Tool title="Красный акцент" className="rte-red" onClick={() => accent(true)}>
|
||||||
|
A
|
||||||
|
</Tool>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<Tool title="Эмодзи" onClick={() => setEmojiOpen((v) => !v)}>
|
||||||
|
😀
|
||||||
|
</Tool>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<Tool title="Убрать оформление" onClick={clearFormat}>
|
||||||
|
✕ формат
|
||||||
|
</Tool>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="rte-editor ann-body"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label="Текст объявления"
|
||||||
|
data-placeholder="Короткий текст объявления…"
|
||||||
|
onInput={emit}
|
||||||
|
// Enter создаёт <p>, а не <div> — абзацы сразу в том виде, что хранит сервер.
|
||||||
|
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")}
|
||||||
|
onPaste={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
|
||||||
|
}}
|
||||||
|
onDrop={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
{emojiOpen && (
|
||||||
|
<div className="rte-emoji">
|
||||||
|
{EMOJI.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertEmoji(emoji)}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,6 +103,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
|
|
||||||
.error-text { color: var(--danger); font-size: 13px; }
|
.error-text { color: var(--danger); font-size: 13px; }
|
||||||
|
|
||||||
|
/* Изменение рейтинга за партию в истории профиля */
|
||||||
|
.rating-delta { color: var(--text-muted); margin-top: 6px; }
|
||||||
|
.rating-up { color: var(--success); }
|
||||||
|
.rating-down { color: var(--danger); }
|
||||||
|
|
||||||
/* Формула в справке */
|
/* Формула в справке */
|
||||||
.formula {
|
.formula {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
@@ -482,3 +487,93 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
.toast-top-error { border-left-color: var(--danger); }
|
.toast-top-error { border-left-color: var(--danger); }
|
||||||
.toast-top-title { font-weight: 700; }
|
.toast-top-title { font-weight: 700; }
|
||||||
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ─── Объявления администрации (#84) ─── */
|
||||||
|
|
||||||
|
/* Окно (вариант E): заголовок — в плашке-шапке на всю ширину модалки. */
|
||||||
|
.ann-modal { max-height: calc(100dvh - 48px); overflow-y: auto; }
|
||||||
|
.ann-ribbon {
|
||||||
|
margin: -18px -18px 14px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
}
|
||||||
|
.ann-ribbon-title { font-weight: 700; font-size: 16px; line-height: 1.3; overflow-wrap: anywhere; }
|
||||||
|
/* Пометка повторного показа — мелкой строкой под заголовком, красным. */
|
||||||
|
.ann-updated { margin-top: 2px; font-size: 12px; color: var(--danger); }
|
||||||
|
.ann-actions { margin-top: 16px; justify-content: space-between; align-items: center; }
|
||||||
|
.ann-dots { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.ann-dots i { display: block; width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
|
||||||
|
.ann-dots i.on { background: var(--accent-2); }
|
||||||
|
|
||||||
|
/* Текст объявления — и в окне, и в редакторе админки (там он выглядит так же). */
|
||||||
|
.ann-body { font-size: 14px; line-height: 1.5; color: var(--text); overflow-wrap: anywhere; }
|
||||||
|
.ann-body p { margin: 0; }
|
||||||
|
.ann-body p + p { margin-top: 10px; }
|
||||||
|
/* Акценты: золотой — цвет ссылок и бейджа «идёт», красный — для предупреждений. */
|
||||||
|
.ann-body mark { background: none; color: var(--accent-2); font-weight: 600; }
|
||||||
|
.ann-body mark.red { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Редактор (вариант R1) */
|
||||||
|
.rte-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
}
|
||||||
|
.rte-tool {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.rte-tool:hover { border-color: var(--accent-2); }
|
||||||
|
.rte-gold { color: var(--accent-2); font-weight: 700; }
|
||||||
|
.rte-red { color: var(--danger); font-weight: 700; }
|
||||||
|
.rte-sep { width: 1px; height: 22px; background: var(--border); }
|
||||||
|
.rte-editor {
|
||||||
|
min-height: 130px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.rte-editor:focus { border-color: var(--accent); }
|
||||||
|
.rte-editor:empty::before { content: attr(data-placeholder); color: var(--text-muted); }
|
||||||
|
.rte-emoji {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.rte-emoji button {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.rte-emoji button:hover { background: var(--surface-2); }
|
||||||
|
.rte-counter { font-size: 12px; color: var(--text-muted); margin-top: 4px; text-align: right; }
|
||||||
|
.rte-counter.over { color: var(--danger); }
|
||||||
|
/* Выделение текста в форме объявления — полупрозрачный --accent-2 вместо системного синего. */
|
||||||
|
.ann-form ::selection { background: rgba(240, 160, 75, 0.35); }
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
[33mcommit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mdev[m[33m)[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Wed Jun 17 04:45:33 2026 +0300
|
|
||||||
|
|
||||||
Добавление ssh-ключа к контейнеру tunnel
|
|
||||||
|
|
||||||
[33mcommit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Wed Jun 17 04:27:11 2026 +0300
|
|
||||||
|
|
||||||
Правки локального размещения дева, перенос тунеля в контейнер
|
|
||||||
|
|
||||||
[33mcommit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 19:15:54 2026 +0300
|
|
||||||
|
|
||||||
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
|
|
||||||
|
|
||||||
[33mcommit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f[m[33m ([m[1;31morigin/main[m[33m, [m[1;32mmain[m[33m)[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 17:41:06 2026 +0300
|
|
||||||
|
|
||||||
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
|
|
||||||
|
|
||||||
[33mcommit 6ab74f01aaf1ac042a269c7660b3e564b93350c7[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 16:53:41 2026 +0300
|
|
||||||
|
|
||||||
first commit
|
|
||||||
@@ -128,11 +128,14 @@ switch ($appEnv) {
|
|||||||
if ($localPublic -eq "vps") {
|
if ($localPublic -eq "vps") {
|
||||||
Start-VpsTunnel 5173
|
Start-VpsTunnel 5173
|
||||||
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
||||||
|
Write-Host " WARNING: dev is public. Anyone can log in by nickname without a password," -ForegroundColor Yellow
|
||||||
|
Write-Host " list/create players, hard-delete accounts and read Swagger." -ForegroundColor Yellow
|
||||||
|
Write-Host " Do not keep a copy of production data in the dev database." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"production" {
|
"production" {
|
||||||
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
||||||
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build"
|
Write-Host "Deploy: PC (main branch) scripts\build-push.ps1, then on Pi: docker compose up -d"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
|
|||||||
@@ -75,12 +75,17 @@ case "$app_env" in
|
|||||||
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
||||||
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
||||||
echo " Фронт: http://127.0.0.1:5173"
|
echo " Фронт: http://127.0.0.1:5173"
|
||||||
[ "$local_public" = "vps" ] && start_tunnel 5173
|
if [ "$local_public" = "vps" ]; then
|
||||||
|
start_tunnel 5173
|
||||||
|
echo " ВНИМАНИЕ: dev опубликован. Любой посетитель может войти по нику без пароля,"
|
||||||
|
echo " смотреть и создавать игроков, жёстко удалять аккаунты и читать Swagger."
|
||||||
|
echo " Не держите в dev-базе копию прод-данных."
|
||||||
|
fi
|
||||||
( cd "$root/frontend" && npm run dev )
|
( cd "$root/frontend" && npm run dev )
|
||||||
;;
|
;;
|
||||||
production)
|
production)
|
||||||
echo "production лаунчером не запускается — прод обособлен."
|
echo "production лаунчером не запускается — прод обособлен."
|
||||||
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
|
echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
|
|
||||||
# прод-контейнера (без тестов, dev-входа и dev-лаунчера).
|
|
||||||
#
|
|
||||||
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
|
|
||||||
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
DEST="${1:?Укажите целевую папку: scripts/export-prod.sh <dir> [ref]}"
|
|
||||||
REF="${2:-HEAD}"
|
|
||||||
|
|
||||||
mkdir -p "$DEST"
|
|
||||||
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
|
|
||||||
git archive --format=tar "$REF" | tar -x -C "$DEST"
|
|
||||||
|
|
||||||
# dev-специфика в проде не нужна (лаунчер)
|
|
||||||
rm -f "$DEST/run.ps1" "$DEST/run.sh"
|
|
||||||
|
|
||||||
echo "[export-prod] Прод выгружен в: $DEST"
|
|
||||||
echo " дальше: cp .env.example .env && docker compose up -d --build"
|
|
||||||
Reference in New Issue
Block a user