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

26 KiB
Raw Permalink Blame History

ТЗ: Скип с изгнанием, модерация и выдача предметов

Обзор

Три связанные фичи:

  1. Скип с изгнанием — новый консамбл, который скипает задание И навсегда исключает игру из пула участника
  2. Модерация марафона — организаторы могут скипать задания у участников (обычный скип / скип с изгнанием)
  3. Выдача предметов админами — UI для системных администраторов для выдачи предметов пользователям

1. Скип с изгнанием (SKIP_EXILE)

1.1 Концепция

Тип скипа Штраф Стрик Игра может выпасть снова
Обычный DROP Да (прогрессивный) Сбрасывается Да (для challenges) / Нет (для playthrough)
SKIP (консамбл) Нет Сохраняется Да (для challenges) / Нет (для playthrough)
SKIP_EXILE Нет Сохраняется Нет

1.2 Backend

Новая модель: ExiledGame

# 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

# 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"

Создание предмета в магазине

# Предмет добавляется через админку или миграцию
ShopItem(
    item_type="consumable",
    code="skip_exile",
    name="Скип с изгнанием",
    description="Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.",
    price=150,  # Дороже обычного скипа (50)
    rarity="rare",
)

Сервис: use_skip_exile

# 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

# 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

Новые эндпоинты

# 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)
    # ...

Схемы

# 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 или отдельная страница модерации):

// Для каждого участника с активным заданием показываем кнопки:
<button onClick={() => skipAssignment(userId, false)}>
  Скип
</button>
<button onClick={() => skipAssignment(userId, true)}>
  Скип с изгнанием
</button>

Модальное окно скипа

<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 уведомления

При модераторском скипе отправляем уведомление участнику:

# 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

# backend/app/models/user.py
class User(Base):
    # ... existing fields ...
    notify_moderation: bool = True  # Уведомления о действиях модераторов

Интеграция в эндпоинт

# В 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

Новые эндпоинты

# 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)
    # ...

Схемы

class AdminGrantItemRequest(BaseModel):
    item_id: int
    quantity: int = 1
    reason: str

3.2 Frontend

Новая страница: AdminItemsPage

frontend/src/pages/admin/AdminItemsPage.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

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>
  )
}

Добавление в роутер

// frontend/src/App.tsx
import { AdminItemsPage } from '@/pages/admin/AdminItemsPage'

// В админских роутах:
<Route path="items" element={<AdminItemsPage />} />

Добавление в меню админки

// 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

# 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

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

# Можно через миграцию или вручную через админку
# Если через миграцию:

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. Телеграм уведомления: Нужны ли уведомления участнику при модераторском скипе?