# ТЗ: Скип с изгнанием, модерация и выдача предметов ## Обзор Три связанные фичи: 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 // Для каждого участника с активным заданием показываем кнопки: ``` #### Модальное окно скипа ```tsx

Скип задания у {participant.nickname}

Текущее задание: {assignment.game.title}