Files
game-marathon/docs/tz-skip-exile-moderation.md
mamonov.ep f78eacb1a5 Добавлен 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>
2026-01-10 23:02:37 +03:00

790 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ: Скип с изгнанием, модерация и выдача предметов
## Обзор
Три связанные фичи:
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. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?