# ТЗ: Типы игр "Прохождение" и "Челленджи" ## Описание задачи Добавить систему типов для игр, которая определяет логику выпадения заданий при спине колеса. ### Два типа игр: | Тип | Название | Поведение при выпадении | |-----|----------|------------------------| | `playthrough` | Прохождение | Основное задание — пройти игру. Челленджи становятся **дополнительными** заданиями | | `challenges` | Челленджи | Выдаётся **случайный челлендж** из списка челленджей игры (текущее поведение) | --- ## Детальное описание логики ### Тип "Прохождение" (`playthrough`) **При создании игры** с типом "Прохождение" указываются дополнительные поля: - **Очки за прохождение** (`playthrough_points`) — количество очков за прохождение игры - **Описание прохождения** (`playthrough_description`) — описание задания (например: "Пройти основной сюжет игры") - **Тип пруфа** (`playthrough_proof_type`) — screenshot / video / steam - **Подсказка для пруфа** (`playthrough_proof_hint`) — опционально (например: "Скриншот финальных титров") **При выпадении игры** с типом "Прохождение": 1. **Основное задание**: Пройти игру (очки и описание берутся из полей игры) 2. **Дополнительные задания**: Все челленджи игры становятся **опциональными** бонусными заданиями 3. **Пруфы**: - Требуется **отдельный пруф на прохождение** игры (тип из `playthrough_proof_type`) - Для каждого бонусного челленджа **тоже требуется пруф** (по типу челленджа) - **Прикрепление файла не обязательно** — можно отправить только комментарий со ссылкой на видео 4. **Система очков**: - За основное прохождение — `playthrough_points` (указанные при создании) - За каждый выполненный доп. челлендж — очки челленджа 5. **Завершение**: Задание считается выполненным после прохождения основной игры. Доп. челленджи **не обязательны** — можно выполнять параллельно или игнорировать ### Тип "Челленджи" (`challenges`) При выпадении игры с типом "Челленджи": 1. Выбирается **один случайный челлендж** из списка челленджей игры 2. Участник выполняет только этот челлендж 3. Логика остаётся **без изменений** (текущее поведение системы) --- ### Фильтрация игр при спине При выборе игры для спина необходимо исключать уже пройденные/дропнутые игры: | Тип игры | Условие исключения из спина | |----------|----------------------------| | `playthrough` | Игра **исключается**, если участник **завершил ИЛИ дропнул** прохождение этой игры | | `challenges` | Игра **исключается**, только если участник выполнил **все** челленджи этой игры | **Логика:** ``` Для каждой игры в марафоне: ЕСЛИ game_type == "playthrough": Проверить: есть ли Assignment с is_playthrough=True для этой игры со статусом COMPLETED или DROPPED? Если да → исключить игру ЕСЛИ game_type == "challenges": Получить все челленджи игры Получить все завершённые Assignment участника для этих челленджей Если количество завершённых == количество челленджей → исключить игру ``` **Важно:** Если все игры исключены (всё пройдено), спин должен вернуть ошибку или специальный статус "Все игры пройдены!" ### Бонусные челленджи Бонусные челленджи доступны **только пока основное задание активно**: - После **завершения** прохождения — бонусные челленджи недоступны - После **дропа** прохождения — бонусные челленджи недоступны - Нельзя вернуться к бонусным челленджам позже ### Взаимодействие с событиями **Все события игнорируются** при выпадении игры с типом `playthrough`: | Событие | Поведение для `playthrough` | |---------|----------------------------| | **JACKPOT** (x3 за hard) | Игнорируется | | **GAME_CHOICE** (выбор из 3) | Игнорируется | | **GOLDEN_HOUR** (x1.5) | Игнорируется | | **DOUBLE_RISK** (x0.5, бесплатный дроп) | Игнорируется | | **COMMON_ENEMY** | Игнорируется | | **SWAP** | Игнорируется | Игрок получает стандартные очки `playthrough_points` без модификаторов. --- ## Изменения в Backend ### 1. Модель Game (`backend/app/models/game.py`) Добавить поля для типа игры и прохождения: ```python class GameType(str, Enum): PLAYTHROUGH = "playthrough" # Прохождение CHALLENGES = "challenges" # Челленджи class Game(Base): # ... существующие поля ... # Тип игры game_type: Mapped[str] = mapped_column( String(20), default=GameType.CHALLENGES.value, nullable=False ) # Поля для типа "Прохождение" (nullable, заполняются только для playthrough) playthrough_points: Mapped[int | None] = mapped_column( Integer, nullable=True ) playthrough_description: Mapped[str | None] = mapped_column( Text, nullable=True ) playthrough_proof_type: Mapped[str | None] = mapped_column( String(20), # screenshot, video, steam nullable=True ) playthrough_proof_hint: Mapped[str | None] = mapped_column( Text, nullable=True ) ``` ### 2. Схемы Pydantic (`backend/app/schemas/`) Обновить схемы для Game: ```python # schemas/game.py class GameType(str, Enum): PLAYTHROUGH = "playthrough" CHALLENGES = "challenges" class GameCreate(BaseModel): # ... существующие поля ... game_type: GameType = GameType.CHALLENGES # Поля для типа "Прохождение" playthrough_points: int | None = None playthrough_description: str | None = None playthrough_proof_type: ProofType | None = None playthrough_proof_hint: str | None = None @model_validator(mode='after') def validate_playthrough_fields(self) -> Self: if self.game_type == GameType.PLAYTHROUGH: if self.playthrough_points is None: raise ValueError('playthrough_points обязателен для типа "Прохождение"') if self.playthrough_description is None: raise ValueError('playthrough_description обязателен для типа "Прохождение"') if self.playthrough_proof_type is None: raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"') if self.playthrough_points < 1 or self.playthrough_points > 500: raise ValueError('playthrough_points должен быть от 1 до 500') return self class GameResponse(BaseModel): # ... существующие поля ... game_type: GameType playthrough_points: int | None playthrough_description: str | None playthrough_proof_type: ProofType | None playthrough_proof_hint: str | None class GameUpdate(BaseModel): """Схема для редактирования игры""" title: str | None = None download_url: str | None = None genre: str | None = None game_type: GameType | None = None playthrough_points: int | None = None playthrough_description: str | None = None playthrough_proof_type: ProofType | None = None playthrough_proof_hint: str | None = None @model_validator(mode='after') def validate_playthrough_fields(self) -> Self: # Валидация только если меняем на playthrough if self.game_type == GameType.PLAYTHROUGH: if self.playthrough_points is not None: if self.playthrough_points < 1 or self.playthrough_points > 500: raise ValueError('playthrough_points должен быть от 1 до 500') return self ``` ### 3. Миграция Alembic ```python # Новая миграция def upgrade(): # Тип игры op.add_column('games', sa.Column( 'game_type', sa.String(20), nullable=False, server_default='challenges' )) # Поля для прохождения op.add_column('games', sa.Column( 'playthrough_points', sa.Integer(), nullable=True )) op.add_column('games', sa.Column( 'playthrough_description', sa.Text(), nullable=True )) op.add_column('games', sa.Column( 'playthrough_proof_type', sa.String(20), nullable=True )) op.add_column('games', sa.Column( 'playthrough_proof_hint', sa.Text(), nullable=True )) def downgrade(): op.drop_column('games', 'playthrough_proof_hint') op.drop_column('games', 'playthrough_proof_type') op.drop_column('games', 'playthrough_description') op.drop_column('games', 'playthrough_points') op.drop_column('games', 'game_type') ``` ### 4. Логика спина (`backend/app/api/v1/wheel.py`) Изменить функцию `spin_wheel`: ```python async def get_available_games( participant: Participant, marathon_games: list[Game], db: AsyncSession ) -> list[Game]: """Получить список игр, доступных для спина""" available = [] for game in marathon_games: if game.game_type == GameType.PLAYTHROUGH.value: # Проверяем, прошёл ли участник эту игру # Исключаем если COMPLETED или DROPPED finished = await db.scalar( select(Assignment) .where( Assignment.participant_id == participant.id, Assignment.game_id == game.id, Assignment.is_playthrough == True, Assignment.status.in_([ AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value ]) ) ) if not finished: available.append(game) else: # GameType.CHALLENGES # Проверяем, остались ли невыполненные челленджи completed_challenge_ids = await db.scalars( select(Assignment.challenge_id) .where( Assignment.participant_id == participant.id, Assignment.challenge_id.in_([c.id for c in game.challenges]), Assignment.status == AssignmentStatus.COMPLETED.value ) ) completed_ids = set(completed_challenge_ids.all()) all_challenge_ids = {c.id for c in game.challenges} if completed_ids != all_challenge_ids: available.append(game) return available async def spin_wheel(...): # Получаем доступные игры (исключаем пройденные) available_games = await get_available_games(participant, marathon_games, db) if not available_games: raise HTTPException( status_code=400, detail="Все игры пройдены! Поздравляем!" ) game = random.choice(available_games) if game.game_type == GameType.PLAYTHROUGH.value: # Для playthrough НЕ выбираем челлендж — основное задание это прохождение # Данные берутся из полей игры: playthrough_points, playthrough_description challenge = None # Или создаём виртуальный объект # Все челленджи игры становятся дополнительными bonus_challenges = list(game.challenges) # Создаём Assignment с флагом is_playthrough=True assignment = Assignment( participant_id=participant.id, challenge_id=None, # Нет привязки к челленджу game_id=game.id, # Новое поле — привязка к игре is_playthrough=True, status=AssignmentStatus.ACTIVE, # ... ) else: # GameType.CHALLENGES # Выбираем случайный НЕВЫПОЛНЕННЫЙ челлендж completed_challenge_ids = await db.scalars( select(Assignment.challenge_id) .where( Assignment.participant_id == participant.id, Assignment.challenge_id.in_([c.id for c in game.challenges]), Assignment.status == AssignmentStatus.COMPLETED.value ) ) completed_ids = set(completed_challenge_ids.all()) available_challenges = [c for c in game.challenges if c.id not in completed_ids] challenge = random.choice(available_challenges) bonus_challenges = [] assignment = Assignment( participant_id=participant.id, challenge_id=challenge.id, is_playthrough=False, status=AssignmentStatus.ACTIVE, # ... ) # ... сохранение Assignment ... ``` ### 5. Модель Assignment (`backend/app/models/assignment.py`) Обновить модель для поддержки прохождений: ```python class Assignment(Base): # ... существующие поля ... # Для прохождений: привязка к игре вместо челленджа game_id: Mapped[int | None] = mapped_column( ForeignKey("games.id"), nullable=True ) is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False) # Relationships game: Mapped["Game"] = relationship(back_populates="playthrough_assignments") # Отдельная таблица для бонусных челленджей class BonusAssignment(Base): __tablename__ = "bonus_assignments" id: Mapped[int] = mapped_column(primary_key=True) main_assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id")) challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id")) status: Mapped[str] = mapped_column(String(20), default="pending") # pending, completed proof_path: Mapped[str | None] = mapped_column(Text, nullable=True) proof_url: Mapped[str | None] = mapped_column(Text, nullable=True) completed_at: Mapped[datetime | None] = mapped_column(nullable=True) points_earned: Mapped[int] = mapped_column(Integer, default=0) # Relationships main_assignment: Mapped["Assignment"] = relationship(back_populates="bonus_assignments") challenge: Mapped["Challenge"] = relationship() ``` ### 6. API эндпоинты Добавить/обновить эндпоинты: ```python # Обновить ответ спина class PlaythroughInfo(BaseModel): """Информация о прохождении (для playthrough игр)""" description: str points: int class SpinResult(BaseModel): assignment_id: int game: GameResponse challenge: ChallengeResponse | None # None для playthrough is_playthrough: bool playthrough_info: PlaythroughInfo | None # Заполняется для playthrough bonus_challenges: list[ChallengeResponse] = [] # Для playthrough can_drop: bool drop_penalty: int # Завершение бонусного челленджа @router.post("/assignments/{assignment_id}/bonus/{challenge_id}/complete") async def complete_bonus_challenge( assignment_id: int, challenge_id: int, proof: ProofData, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ) -> BonusAssignmentResponse: """Завершить дополнительный челлендж для игры-прохождения""" ... # Получение бонусных челленджей @router.get("/assignments/{assignment_id}/bonus") async def get_bonus_assignments( assignment_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ) -> list[BonusAssignmentResponse]: """Получить список бонусных челленджей и их статус""" ... # Получение количества доступных игр для спина @router.get("/marathons/{marathon_id}/available-games-count") async def get_available_games_count( marathon_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ) -> dict: """ Получить количество игр, доступных для спина. Возвращает: { "available": 5, "total": 10 } """ participant = await get_participant(...) marathon_games = await get_marathon_games(...) available = await get_available_games(participant, marathon_games, db) return { "available": len(available), "total": len(marathon_games) } # Редактирование игры @router.patch("/marathons/{marathon_id}/games/{game_id}") async def update_game( marathon_id: int, game_id: int, game_data: GameUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ) -> GameResponse: """ Редактировать игру. Доступно только организатору марафона. При смене типа на 'playthrough' необходимо указать playthrough_points и playthrough_description. """ # Проверка прав (организатор) # Валидация: если меняем тип на playthrough, проверить что поля заполнены # Обновление полей ... ``` --- ## Изменения в Frontend ### 1. Типы (`frontend/src/types/index.ts`) ```typescript export type GameType = 'playthrough' | 'challenges' export interface Game { // ... существующие поля ... game_type: GameType playthrough_points: number | null playthrough_description: string | null } export interface PlaythroughInfo { description: string points: number } export interface SpinResult { assignment_id: number game: Game challenge: Challenge | null // null для playthrough is_playthrough: boolean playthrough_info: PlaythroughInfo | null bonus_challenges: Challenge[] can_drop: boolean drop_penalty: number } export interface BonusAssignment { id: number challenge: Challenge status: 'pending' | 'completed' proof_url: string | null completed_at: string | null points_earned: number } export interface GameUpdate { title?: string download_url?: string genre?: string game_type?: GameType playthrough_points?: number playthrough_description?: string } ``` ### 2. Форма добавления игры Добавить выбор типа игры и условные поля: ```tsx // components/AddGameForm.tsx const [gameType, setGameType] = useState('challenges') const [playthroughPoints, setPlaythroughPoints] = useState(100) const [playthroughDescription, setPlaythroughDescription] = useState('') return (
{/* ... существующие поля ... */}