Добавлен 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:
@@ -3,6 +3,7 @@ Consumables Service - handles consumable items usage
|
||||
|
||||
Consumables:
|
||||
- skip: Skip current assignment without penalty
|
||||
- skip_exile: Skip + permanently exile game from pool
|
||||
- boost: x1.5 multiplier for current assignment
|
||||
- wild_card: Choose a game, get random challenge from it
|
||||
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
|
||||
@@ -19,7 +20,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.models import (
|
||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
||||
BonusAssignment
|
||||
BonusAssignment, ExiledGame
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +99,110 @@ class ConsumablesService:
|
||||
"streak_preserved": True,
|
||||
}
|
||||
|
||||
async def use_skip_exile(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Skip with Exile - skip assignment AND permanently exile game from pool.
|
||||
|
||||
- No streak loss
|
||||
- No drop penalty
|
||||
- Game is permanently excluded from participant's pool
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If skips not allowed or limit reached
|
||||
"""
|
||||
# Check marathon settings (same as regular skip)
|
||||
if not marathon.allow_skips:
|
||||
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
|
||||
|
||||
if marathon.max_skips_per_participant is not None:
|
||||
if participant.skips_used >= marathon.max_skips_per_participant:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
|
||||
)
|
||||
|
||||
# Check assignment is active
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only skip active assignments")
|
||||
|
||||
# Get game_id (different for playthrough vs challenges)
|
||||
if assignment.is_playthrough:
|
||||
game_id = assignment.game_id
|
||||
else:
|
||||
# Need to load challenge to get game_id
|
||||
if assignment.challenge:
|
||||
game_id = assignment.challenge.game_id
|
||||
else:
|
||||
# Load challenge if not already loaded
|
||||
result = await db.execute(
|
||||
select(Challenge).where(Challenge.id == assignment.challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one()
|
||||
game_id = challenge.game_id
|
||||
|
||||
# Check if game is already exiled
|
||||
existing = await db.execute(
|
||||
select(ExiledGame).where(
|
||||
ExiledGame.participant_id == participant.id,
|
||||
ExiledGame.game_id == game_id,
|
||||
ExiledGame.is_active == True,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Game is already exiled")
|
||||
|
||||
# Consume skip_exile from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
|
||||
|
||||
# Mark assignment as dropped (without penalty)
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
|
||||
# Track skip usage
|
||||
participant.skips_used += 1
|
||||
|
||||
# Add game to exiled list
|
||||
exiled = ExiledGame(
|
||||
participant_id=participant.id,
|
||||
game_id=game_id,
|
||||
assignment_id=assignment.id,
|
||||
exiled_by="user",
|
||||
)
|
||||
db.add(exiled)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "skip_exile",
|
||||
"skipped_without_penalty": True,
|
||||
"game_exiled": True,
|
||||
"game_id": game_id,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"skipped": True,
|
||||
"exiled": True,
|
||||
"game_id": game_id,
|
||||
"penalty": 0,
|
||||
"streak_preserved": True,
|
||||
}
|
||||
|
||||
async def use_boost(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
Reference in New Issue
Block a user