Добавлен Skip with Exile, модерация марафонов и выдача предметов

## Skip with Exile (новый расходник)
- Новая модель ExiledGame для хранения изгнанных игр
- Расходник skip_exile: пропуск без штрафа + игра исключается из пула навсегда
- Фильтрация изгнанных игр при выдаче заданий
- UI кнопка в PlayPage для использования skip_exile

## Модерация марафонов (для организаторов)
- Эндпоинты: skip-assignment, exiled-games, restore-exiled-game
- UI в LeaderboardPage: кнопка скипа у каждого участника
- Выбор типа скипа (обычный/с изгнанием) + причина
- Telegram уведомления о модерации

## Админская выдача предметов
- Эндпоинты: admin grant/remove items, get user inventory
- Новая страница AdminGrantItemPage (как магазин)
- Telegram уведомление при получении подарка

## Исправления миграций
- Миграции 029/030 теперь идемпотентны (проверка существования таблиц)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-10 23:01:23 +03:00
parent cf0df928b1
commit f78eacb1a5
24 changed files with 2194 additions and 14 deletions

View File

@@ -0,0 +1,789 @@
# ТЗ: Скип с изгнанием, модерация и выдача предметов
## Обзор
Три связанные фичи:
1. **Скип с изгнанием** — новый консамбл, который скипает задание И навсегда исключает игру из пула участника
2. **Модерация марафона** — организаторы могут скипать задания у участников (обычный скип / скип с изгнанием)
3. **Выдача предметов админами** — UI для системных администраторов для выдачи предметов пользователям
---
## 1. Скип с изгнанием (SKIP_EXILE)
### 1.1 Концепция
| Тип скипа | Штраф | Стрик | Игра может выпасть снова |
|-----------|-------|-------|--------------------------|
| Обычный DROP | Да (прогрессивный) | Сбрасывается | Да (для challenges) / Нет (для playthrough) |
| SKIP (консамбл) | Нет | Сохраняется | Да (для challenges) / Нет (для playthrough) |
| **SKIP_EXILE** | Нет | Сохраняется | **Нет** |
### 1.2 Backend
#### Новая модель: ExiledGame
```python
# backend/app/models/exiled_game.py
class ExiledGame(Base):
__tablename__ = "exiled_games"
__table_args__ = (
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
)
id: int (PK)
participant_id: int (FK -> participants.id, ondelete=CASCADE)
game_id: int (FK -> games.id, ondelete=CASCADE)
assignment_id: int | None (FK -> assignments.id) # Какое задание было при изгнании
exiled_at: datetime
exiled_by: str # "user" | "organizer" | "admin"
reason: str | None # Опциональная причина
# История восстановления (soft-delete pattern)
is_active: bool = True # False = игра возвращена в пул
unexiled_at: datetime | None
unexiled_by: str | None # "organizer" | "admin"
```
> **Примечание**: При восстановлении игры запись НЕ удаляется, а помечается `is_active=False`.
> Это сохраняет историю изгнаний для аналитики и разрешения споров.
#### Новый ConsumableType
```python
# backend/app/models/shop.py
class ConsumableType(str, Enum):
SKIP = "skip"
SKIP_EXILE = "skip_exile" # NEW
BOOST = "boost"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"
COPYCAT = "copycat"
UNDO = "undo"
```
#### Создание предмета в магазине
```python
# Предмет добавляется через админку или миграцию
ShopItem(
item_type="consumable",
code="skip_exile",
name="Скип с изгнанием",
description="Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.",
price=150, # Дороже обычного скипа (50)
rarity="rare",
)
```
#### Сервис: use_skip_exile
```python
# backend/app/services/consumables.py
async def use_skip_exile(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Skip assignment AND exile the game permanently.
- No streak loss
- No drop penalty
- Game is permanently excluded from participant's pool
"""
# Проверки как у обычного skip
if not marathon.allow_skips:
raise HTTPException(400, "Skips not allowed")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(400, "Skip limit reached")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(400, "Can only skip active assignments")
# Получаем game_id
if assignment.is_playthrough:
game_id = assignment.game_id
else:
game_id = assignment.challenge.game_id
# Consume from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
# Mark assignment as dropped (без штрафа)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Track skip usage
participant.skips_used += 1
# НОВОЕ: Добавляем игру в exiled
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
exiled_by="user",
)
db.add(exiled)
# Log usage
usage = ConsumableUsage(...)
db.add(usage)
return {
"success": True,
"skipped": True,
"exiled": True,
"game_id": game_id,
"penalty": 0,
"streak_preserved": True,
}
```
#### Изменение get_available_games_for_participant
```python
# backend/app/api/v1/games.py
async def get_available_games_for_participant(...):
# ... existing code ...
# НОВОЕ: Получаем изгнанные игры
exiled_result = await db.execute(
select(ExiledGame.game_id)
.where(ExiledGame.participant_id == participant.id)
)
exiled_game_ids = set(exiled_result.scalars().all())
# Фильтруем доступные игры
available_games = []
for game in games_with_content:
# НОВОЕ: Исключаем изгнанные игры
if game.id in exiled_game_ids:
continue
if game.game_type == GameType.PLAYTHROUGH.value:
if game.id not in finished_playthrough_game_ids:
available_games.append(game)
else:
# ...existing logic...
```
### 1.3 Frontend
#### Обновление UI использования консамблов
- В `PlayPage.tsx` добавить кнопку "Скип с изгнанием" рядом с обычным скипом
- Показывать предупреждение: "Игра будет навсегда исключена из вашего пула"
- В инвентаре показывать оба типа скипов отдельно
### 1.4 API Endpoints
```
POST /shop/use
Body: {
"item_code": "skip_exile",
"marathon_id": 123,
"assignment_id": 456
}
Response: {
"success": true,
"remaining_quantity": 2,
"effect_description": "Задание пропущено, игра изгнана",
"effect_data": {
"skipped": true,
"exiled": true,
"game_id": 789,
"penalty": 0,
"streak_preserved": true
}
}
```
---
## 2. Модерация марафона (скипы организаторами)
### 2.1 Концепция
Организаторы марафона могут скипать задания у участников:
- **Скип** — пропустить задание без штрафа (игра может выпасть снова)
- **Скип с изгнанием** — пропустить и исключить игру из пула участника
Причины использования:
- Участник просит пропустить игру (технические проблемы, неподходящая игра)
- Модерация спорных ситуаций
- Исправление ошибок
### 2.2 Backend
#### Новые эндпоинты
```python
# backend/app/api/v1/marathons.py
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment")
async def organizer_skip_assignment(
marathon_id: int,
user_id: int,
data: OrganizerSkipRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Организатор скипает текущее задание участника.
Body:
exile: bool = False # Если true — скип с изгнанием
reason: str | None # Причина (опционально)
"""
await require_organizer(db, current_user, marathon_id)
# Получаем участника
participant = await get_participant_by_user_id(db, user_id, marathon_id)
if not participant:
raise HTTPException(404, "Participant not found")
# Получаем активное задание
assignment = await get_active_assignment(db, participant.id)
if not assignment:
raise HTTPException(400, "No active assignment")
# Определяем game_id
if assignment.is_playthrough:
game_id = assignment.game_id
else:
game_id = assignment.challenge.game_id
# Скипаем
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# НЕ увеличиваем skips_used (это модераторский скип, не консамбл)
# НЕ сбрасываем стрик
# НЕ увеличиваем drop_count
# Если exile — добавляем в exiled
if data.exile:
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
exiled_by="organizer",
reason=data.reason,
)
db.add(exiled)
# Логируем в Activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.MODERATION.value,
data={
"action": "skip_assignment",
"target_user_id": user_id,
"assignment_id": assignment.id,
"game_id": game_id,
"exile": data.exile,
"reason": data.reason,
}
)
db.add(activity)
await db.commit()
return {"success": True, "exiled": data.exile}
@router.get("/{marathon_id}/participants/{user_id}/exiled-games")
async def get_participant_exiled_games(
marathon_id: int,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Список изгнанных игр участника (для организаторов)"""
await require_organizer(db, current_user, marathon_id)
# ...
@router.delete("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}")
async def remove_exiled_game(
marathon_id: int,
user_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Убрать игру из изгнанных (вернуть в пул)"""
await require_organizer(db, current_user, marathon_id)
# ...
```
#### Схемы
```python
# backend/app/schemas/marathon.py
class OrganizerSkipRequest(BaseModel):
exile: bool = False
reason: str | None = None
class ExiledGameResponse(BaseModel):
id: int
game_id: int
game_title: str
exiled_at: datetime
exiled_by: str
reason: str | None
```
### 2.3 Frontend
#### Страница участников марафона
В списке участников (`MarathonPage.tsx` или отдельная страница модерации):
```tsx
// Для каждого участника с активным заданием показываем кнопки:
<button onClick={() => skipAssignment(userId, false)}>
Скип
</button>
<button onClick={() => skipAssignment(userId, true)}>
Скип с изгнанием
</button>
```
#### Модальное окно скипа
```tsx
<Modal>
<h2>Скип задания у {participant.nickname}</h2>
<p>Текущее задание: {assignment.game.title}</p>
<label>
<input type="checkbox" checked={exile} onChange={...} />
Изгнать игру (не будет выпадать снова)
</label>
<textarea placeholder="Причина (опционально)" />
<button>Подтвердить</button>
</Modal>
```
### 2.4 Telegram уведомления
При модераторском скипе отправляем уведомление участнику:
```python
# backend/app/services/telegram_notifier.py
async def notify_assignment_skipped_by_moderator(
user: User,
marathon_title: str,
game_title: str,
exiled: bool,
reason: str | None,
moderator_nickname: str,
):
"""Уведомление о скипе задания организатором"""
if not user.telegram_id or not user.notify_moderation:
return
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
reason_text = f"\n📝 Причина: {reason}" if reason else ""
message = f"""⏭️ <b>Задание пропущено</b>
Марафон: {marathon_title}
Игра: {game_title}
Организатор: {moderator_nickname}{exile_text}{reason_text}
Вы можете крутить колесо заново."""
await self._send_message(user.telegram_id, message)
```
#### Добавить поле notify_moderation в User
```python
# backend/app/models/user.py
class User(Base):
# ... existing fields ...
notify_moderation: bool = True # Уведомления о действиях модераторов
```
#### Интеграция в эндпоинт
```python
# В organizer_skip_assignment после db.commit():
await telegram_notifier.notify_assignment_skipped_by_moderator(
user=target_user,
marathon_title=marathon.title,
game_title=game.title,
exiled=data.exile,
reason=data.reason,
moderator_nickname=current_user.nickname,
)
```
---
## 3. Выдача предметов админами
### 3.1 Backend
#### Новые эндпоинты
```python
# backend/app/api/v1/shop.py
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
async def admin_grant_item(
user_id: int,
data: AdminGrantItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Выдать предмет пользователю (admin only).
Body:
item_id: int # ID предмета в магазине
quantity: int = 1 # Количество (для консамблов)
reason: str # Причина выдачи
"""
require_admin_with_2fa(current_user)
# Получаем пользователя
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(404, "User not found")
# Получаем предмет
item = await shop_service.get_item_by_id(db, data.item_id)
if not item:
raise HTTPException(404, "Item not found")
# Проверяем quantity для не-консамблов
if item.item_type != "consumable" and data.quantity > 1:
raise HTTPException(400, "Non-consumables can only have quantity 1")
# Проверяем, есть ли уже такой предмет
existing = await db.execute(
select(UserInventory)
.where(
UserInventory.user_id == user_id,
UserInventory.item_id == item.id,
)
)
inv_item = existing.scalar_one_or_none()
if inv_item:
if item.item_type == "consumable":
inv_item.quantity += data.quantity
else:
raise HTTPException(400, "User already owns this item")
else:
inv_item = UserInventory(
user_id=user_id,
item_id=item.id,
quantity=data.quantity if item.item_type == "consumable" else 1,
)
db.add(inv_item)
# Логируем
log = AdminLog(
admin_id=current_user.id,
action="ITEM_GRANT",
target_type="user",
target_id=user_id,
details={
"item_id": item.id,
"item_name": item.name,
"quantity": data.quantity,
"reason": data.reason,
}
)
db.add(log)
await db.commit()
return MessageResponse(
message=f"Granted {data.quantity}x {item.name} to {user.nickname}"
)
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
async def admin_get_user_inventory(
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Получить инвентарь пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
async def admin_remove_item(
user_id: int,
inventory_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Удалить предмет из инвентаря пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
```
#### Схемы
```python
class AdminGrantItemRequest(BaseModel):
item_id: int
quantity: int = 1
reason: str
```
### 3.2 Frontend
#### Новая страница: AdminItemsPage
`frontend/src/pages/admin/AdminItemsPage.tsx`
```tsx
export function AdminItemsPage() {
const [users, setUsers] = useState<User[]>([])
const [items, setItems] = useState<ShopItem[]>([])
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [grantModal, setGrantModal] = useState(false)
return (
<div>
<h1>Выдача предметов</h1>
{/* Поиск пользователя */}
<UserSearch onSelect={setSelectedUser} />
{selectedUser && (
<>
{/* Информация о пользователе */}
<UserCard user={selectedUser} />
{/* Инвентарь пользователя */}
<h2>Инвентарь</h2>
<UserInventoryList userId={selectedUser.id} />
{/* Кнопка выдачи */}
<button onClick={() => setGrantModal(true)}>
Выдать предмет
</button>
</>
)}
{/* Модалка выдачи */}
<GrantItemModal
isOpen={grantModal}
user={selectedUser}
items={items}
onClose={() => setGrantModal(false)}
onGrant={handleGrant}
/>
</div>
)
}
```
#### Компонент GrantItemModal
```tsx
function GrantItemModal({ isOpen, user, items, onClose, onGrant }) {
const [itemId, setItemId] = useState<number | null>(null)
const [quantity, setQuantity] = useState(1)
const [reason, setReason] = useState("")
const selectedItem = items.find(i => i.id === itemId)
const isConsumable = selectedItem?.item_type === "consumable"
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>Выдать предмет: {user?.nickname}</h2>
{/* Выбор предмета */}
<select value={itemId} onChange={e => setItemId(Number(e.target.value))}>
<option value="">Выберите предмет</option>
{items.map(item => (
<option key={item.id} value={item.id}>
{item.name} ({item.item_type})
</option>
))}
</select>
{/* Количество (только для консамблов) */}
{isConsumable && (
<input
type="number"
min={1}
max={100}
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
/>
)}
{/* Причина */}
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Причина выдачи (обязательно)"
required
/>
<button
onClick={() => onGrant({ itemId, quantity, reason })}
disabled={!itemId || !reason}
>
Выдать
</button>
</Modal>
)
}
```
#### Добавление в роутер
```tsx
// frontend/src/App.tsx
import { AdminItemsPage } from '@/pages/admin/AdminItemsPage'
// В админских роутах:
<Route path="items" element={<AdminItemsPage />} />
```
#### Добавление в меню админки
```tsx
// frontend/src/pages/admin/AdminLayout.tsx
const adminLinks = [
{ path: '/admin', label: 'Дашборд' },
{ path: '/admin/users', label: 'Пользователи' },
{ path: '/admin/marathons', label: 'Марафоны' },
{ path: '/admin/items', label: 'Предметы' }, // NEW
{ path: '/admin/promo', label: 'Промокоды' },
// ...
]
```
---
## 4. Миграции
### 4.1 Создание таблицы exiled_games
```python
# backend/alembic/versions/XXX_add_exiled_games.py
def upgrade():
op.create_table(
'exiled_games',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('participant_id', sa.Integer(), sa.ForeignKey('participants.id', ondelete='CASCADE'), nullable=False),
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id', ondelete='CASCADE'), nullable=False),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='SET NULL'), nullable=True),
sa.Column('exiled_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('exiled_by', sa.String(20), nullable=False), # user, organizer, admin
sa.Column('reason', sa.String(500), nullable=True),
# История восстановления
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
sa.Column('unexiled_by', sa.String(20), nullable=True),
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
)
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
def downgrade():
op.drop_table('exiled_games')
```
### 4.2 Добавление поля notify_moderation в users
```python
def upgrade():
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
def downgrade():
op.drop_column('users', 'notify_moderation')
```
### 4.3 Добавление предмета skip_exile
```python
# Можно через миграцию или вручную через админку
# Если через миграцию:
def upgrade():
# ... create table ...
# Добавляем предмет в магазин
op.execute("""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, is_active, created_at)
VALUES (
'consumable',
'skip_exile',
'Скип с изгнанием',
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.',
150,
'rare',
true,
NOW()
)
""")
```
---
## 5. Чеклист реализации
### Backend — Модели и миграции
- [ ] Создать модель ExiledGame (с полями assignment_id, is_active, unexiled_at, unexiled_by)
- [ ] Добавить поле notify_moderation в User
- [ ] Добавить ConsumableType.SKIP_EXILE
- [ ] Написать миграцию для exiled_games
- [ ] Написать миграцию для notify_moderation
- [ ] Добавить предмет skip_exile в магазин
### Backend — Скип с изгнанием
- [ ] Реализовать use_skip_exile в ConsumablesService
- [ ] Обновить get_available_games_for_participant (фильтр по is_active=True)
- [ ] Добавить обработку skip_exile в POST /shop/use
### Backend — Модерация
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/skip-assignment
- [ ] Добавить эндпоинт GET /{marathon_id}/participants/{user_id}/exiled-games
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore
- [ ] Добавить notify_assignment_skipped_by_moderator в telegram_notifier
### Backend — Админка предметов
- [ ] Добавить эндпоинт POST /shop/admin/users/{user_id}/items/grant
- [ ] Добавить эндпоинт GET /shop/admin/users/{user_id}/inventory
- [ ] Добавить эндпоинт DELETE /shop/admin/users/{user_id}/inventory/{inventory_id}
### Frontend — Игрок
- [ ] Добавить кнопку "Скип с изгнанием" в PlayPage
- [ ] Добавить чекбокс notify_moderation в настройках профиля
### Frontend — Админка
- [ ] Создать AdminItemsPage
- [ ] Добавить GrantItemModal
- [ ] Добавить роут /admin/items
- [ ] Добавить пункт меню в AdminLayout
### Frontend — Модерация марафона
- [ ] Создать UI модерации для организаторов (скип заданий)
- [ ] Добавить список изгнанных игр участника
- [ ] Добавить кнопку восстановления игры в пул
### Тестирование
- [ ] Тест: use_skip_exile корректно исключает игру
- [ ] Тест: изгнанная игра не выпадает при спине
- [ ] Тест: восстановленная игра (is_active=False) снова выпадает
- [ ] Тест: организатор может скипать задания
- [ ] Тест: Telegram уведомление отправляется при модераторском скипе
- [ ] Тест: админ может выдавать предметы
- [ ] Тест: лимиты скипов работают корректно
---
## 6. Вопросы для обсуждения
1. **Лимиты изгнания**: Нужен ли лимит на количество изгнанных игр у участника?
2. **Отмена изгнания**: Может ли участник сам отменить изгнание? Или только организатор?
3. **Стоимость**: Текущая цена skip_exile = 150 монет (обычный skip = 50). Подходит?
4. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?