2026-01-05 07:15:50 +07:00
|
|
|
"""
|
2026-01-08 08:49:51 +07:00
|
|
|
Consumables Service - handles consumable items usage
|
|
|
|
|
|
|
|
|
|
Consumables:
|
|
|
|
|
- skip: Skip current assignment without penalty
|
2026-01-10 23:01:23 +03:00
|
|
|
- skip_exile: Skip + permanently exile game from pool
|
2026-01-08 08:49:51 +07:00
|
|
|
- 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)
|
|
|
|
|
- copycat: Copy another participant's assignment
|
|
|
|
|
- undo: Restore points and streak from last drop
|
2026-01-05 07:15:50 +07:00
|
|
|
"""
|
2026-01-08 08:49:51 +07:00
|
|
|
import random
|
2026-01-08 06:51:15 +07:00
|
|
|
from datetime import datetime
|
2026-01-05 07:15:50 +07:00
|
|
|
from fastapi import HTTPException
|
2026-01-08 08:49:51 +07:00
|
|
|
from sqlalchemy import select, func
|
2026-01-05 07:15:50 +07:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
|
|
|
|
|
from app.models import (
|
|
|
|
|
User, Participant, Marathon, Assignment, AssignmentStatus,
|
2026-01-08 08:49:51 +07:00
|
|
|
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
2026-01-16 18:10:40 +03:00
|
|
|
BonusAssignment, ExiledGame, GameType
|
2026-01-05 07:15:50 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ConsumablesService:
|
|
|
|
|
"""Service for consumable items"""
|
|
|
|
|
|
|
|
|
|
# Boost settings
|
|
|
|
|
BOOST_MULTIPLIER = 1.5
|
|
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
# Lucky Dice multipliers (equal probability, starts from 1.5x)
|
|
|
|
|
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
async def use_skip(
|
|
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user: User,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
marathon: Marathon,
|
|
|
|
|
assignment: Assignment,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Use a Skip to bypass current assignment without penalty.
|
|
|
|
|
|
|
|
|
|
- No streak loss
|
|
|
|
|
- No drop penalty
|
|
|
|
|
- Assignment marked as dropped but without negative effects
|
|
|
|
|
|
|
|
|
|
Returns: dict with result info
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If skips not allowed or limit reached
|
|
|
|
|
"""
|
|
|
|
|
# Check marathon settings
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
# Consume skip from inventory
|
|
|
|
|
item = await self._consume_item(db, user, ConsumableType.SKIP.value)
|
|
|
|
|
|
|
|
|
|
# Mark assignment as dropped (but without penalty)
|
|
|
|
|
assignment.status = AssignmentStatus.DROPPED.value
|
|
|
|
|
assignment.completed_at = datetime.utcnow()
|
|
|
|
|
# Note: We do NOT increase drop_count or reset streak
|
|
|
|
|
|
|
|
|
|
# Track skip usage
|
|
|
|
|
participant.skips_used += 1
|
|
|
|
|
|
|
|
|
|
# Log usage
|
|
|
|
|
usage = ConsumableUsage(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
item_id=item.id,
|
|
|
|
|
marathon_id=marathon.id,
|
|
|
|
|
assignment_id=assignment.id,
|
|
|
|
|
effect_data={
|
|
|
|
|
"type": "skip",
|
|
|
|
|
"skipped_without_penalty": True,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(usage)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"skipped": True,
|
|
|
|
|
"penalty": 0,
|
|
|
|
|
"streak_preserved": True,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 23:01:23 +03:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
async def use_boost(
|
2026-01-05 07:15:50 +07:00
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user: User,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
marathon: Marathon,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""
|
2026-01-08 08:49:51 +07:00
|
|
|
Activate a Boost - multiplies points for current assignment on complete.
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
|
|
|
|
|
- One-time use (consumed on complete)
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
Returns: dict with result info
|
|
|
|
|
|
|
|
|
|
Raises:
|
2026-01-08 08:49:51 +07:00
|
|
|
HTTPException: If consumables not allowed or boost already active
|
2026-01-05 07:15:50 +07:00
|
|
|
"""
|
|
|
|
|
if not marathon.allow_consumables:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
|
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
if participant.has_active_boost:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Boost is already activated")
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
# Consume boost from inventory
|
|
|
|
|
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
# Activate boost (one-time use)
|
|
|
|
|
participant.has_active_boost = True
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
# Log usage
|
|
|
|
|
usage = ConsumableUsage(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
item_id=item.id,
|
|
|
|
|
marathon_id=marathon.id,
|
|
|
|
|
effect_data={
|
2026-01-08 08:49:51 +07:00
|
|
|
"type": "boost",
|
|
|
|
|
"multiplier": self.BOOST_MULTIPLIER,
|
|
|
|
|
"one_time": True,
|
2026-01-05 07:15:50 +07:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(usage)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
2026-01-08 08:49:51 +07:00
|
|
|
"boost_activated": True,
|
|
|
|
|
"multiplier": self.BOOST_MULTIPLIER,
|
2026-01-05 07:15:50 +07:00
|
|
|
}
|
|
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
async def use_wild_card(
|
2026-01-05 07:15:50 +07:00
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user: User,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
marathon: Marathon,
|
2026-01-08 08:49:51 +07:00
|
|
|
assignment: Assignment,
|
|
|
|
|
game_id: int,
|
2026-01-05 07:15:50 +07:00
|
|
|
) -> dict:
|
|
|
|
|
"""
|
2026-01-16 18:10:40 +03:00
|
|
|
Use Wild Card - choose a game and switch to it.
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-16 18:10:40 +03:00
|
|
|
For challenges game type:
|
2026-01-08 08:49:51 +07:00
|
|
|
- New challenge is randomly selected from the chosen game
|
2026-01-16 18:10:40 +03:00
|
|
|
- Assignment becomes a regular challenge
|
|
|
|
|
|
|
|
|
|
For playthrough game type:
|
|
|
|
|
- Assignment becomes a playthrough of the chosen game
|
|
|
|
|
- Bonus assignments are created from game's challenges
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
Returns: dict with new assignment info
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
Raises:
|
2026-01-08 08:49:51 +07:00
|
|
|
HTTPException: If game not in marathon or no challenges available
|
2026-01-05 07:15:50 +07:00
|
|
|
"""
|
|
|
|
|
if not marathon.allow_consumables:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
|
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-16 18:10:40 +03:00
|
|
|
# Verify game is in this marathon and load challenges
|
2026-01-08 08:49:51 +07:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(Game)
|
2026-01-16 18:10:40 +03:00
|
|
|
.options(selectinload(Game.challenges))
|
2026-01-08 08:49:51 +07:00
|
|
|
.where(
|
|
|
|
|
Game.id == game_id,
|
|
|
|
|
Game.marathon_id == marathon.id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
game = result.scalar_one_or_none()
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
if not game:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Game not found in this marathon")
|
|
|
|
|
|
2026-01-16 18:10:40 +03:00
|
|
|
# Store old assignment info for logging
|
|
|
|
|
old_game_id = assignment.game_id
|
|
|
|
|
old_challenge_id = assignment.challenge_id
|
|
|
|
|
old_is_playthrough = assignment.is_playthrough
|
2026-01-08 08:49:51 +07:00
|
|
|
|
|
|
|
|
# Consume wild card from inventory
|
|
|
|
|
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
|
|
|
|
|
|
2026-01-16 18:10:40 +03:00
|
|
|
# Delete existing bonus assignments if any
|
|
|
|
|
if assignment.bonus_assignments:
|
|
|
|
|
for ba in assignment.bonus_assignments:
|
|
|
|
|
await db.delete(ba)
|
|
|
|
|
|
|
|
|
|
new_challenge_id = None
|
|
|
|
|
new_challenge_title = None
|
|
|
|
|
|
|
|
|
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
|
|
|
|
# Switch to playthrough mode
|
|
|
|
|
assignment.game_id = game_id
|
|
|
|
|
assignment.challenge_id = None
|
|
|
|
|
assignment.is_playthrough = True
|
|
|
|
|
|
|
|
|
|
# Create bonus assignments from game's challenges
|
|
|
|
|
for ch in game.challenges:
|
|
|
|
|
bonus = BonusAssignment(
|
|
|
|
|
main_assignment_id=assignment.id,
|
|
|
|
|
challenge_id=ch.id,
|
|
|
|
|
)
|
|
|
|
|
db.add(bonus)
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
# Switch to challenge mode - get random challenge
|
|
|
|
|
if not game.challenges:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
|
|
|
|
|
|
|
|
|
new_challenge = random.choice(game.challenges)
|
|
|
|
|
new_challenge_id = new_challenge.id
|
|
|
|
|
new_challenge_title = new_challenge.title
|
|
|
|
|
|
|
|
|
|
assignment.game_id = game_id
|
|
|
|
|
assignment.challenge_id = new_challenge_id
|
|
|
|
|
assignment.is_playthrough = False
|
2026-01-08 08:49:51 +07:00
|
|
|
|
2026-01-16 18:10:40 +03:00
|
|
|
# Reset timestamps since it's a new assignment
|
2026-01-08 08:49:51 +07:00
|
|
|
assignment.started_at = datetime.utcnow()
|
2026-01-16 18:10:40 +03:00
|
|
|
assignment.deadline = None
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
# Log usage
|
|
|
|
|
usage = ConsumableUsage(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
item_id=item.id,
|
|
|
|
|
marathon_id=marathon.id,
|
2026-01-08 08:49:51 +07:00
|
|
|
assignment_id=assignment.id,
|
2026-01-05 07:15:50 +07:00
|
|
|
effect_data={
|
2026-01-08 08:49:51 +07:00
|
|
|
"type": "wild_card",
|
|
|
|
|
"old_game_id": old_game_id,
|
|
|
|
|
"old_challenge_id": old_challenge_id,
|
2026-01-16 18:10:40 +03:00
|
|
|
"old_is_playthrough": old_is_playthrough,
|
2026-01-08 08:49:51 +07:00
|
|
|
"new_game_id": game_id,
|
2026-01-16 18:10:40 +03:00
|
|
|
"new_challenge_id": new_challenge_id,
|
|
|
|
|
"new_is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
|
2026-01-05 07:15:50 +07:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(usage)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
2026-01-08 08:49:51 +07:00
|
|
|
"game_id": game_id,
|
2026-01-16 18:10:40 +03:00
|
|
|
"game_name": game.title,
|
|
|
|
|
"game_type": game.game_type,
|
|
|
|
|
"is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
|
|
|
|
|
"challenge_id": new_challenge_id,
|
|
|
|
|
"challenge_title": new_challenge_title,
|
2026-01-08 08:49:51 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def use_lucky_dice(
|
|
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user: User,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
marathon: Marathon,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Use Lucky Dice - get a random multiplier for current assignment.
|
|
|
|
|
|
|
|
|
|
- Random multiplier from [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
|
|
|
|
|
- Applied on next complete (stacks with boost if both active)
|
|
|
|
|
- One-time use
|
|
|
|
|
|
|
|
|
|
Returns: dict with rolled multiplier
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If consumables not allowed or lucky dice already active
|
|
|
|
|
"""
|
|
|
|
|
if not marathon.allow_consumables:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
|
|
|
|
|
|
|
|
|
if participant.has_lucky_dice:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Lucky Dice is already active")
|
|
|
|
|
|
|
|
|
|
# Consume lucky dice from inventory
|
|
|
|
|
item = await self._consume_item(db, user, ConsumableType.LUCKY_DICE.value)
|
|
|
|
|
|
|
|
|
|
# Roll the dice
|
|
|
|
|
multiplier = random.choice(self.LUCKY_DICE_MULTIPLIERS)
|
|
|
|
|
|
|
|
|
|
# Activate lucky dice
|
|
|
|
|
participant.has_lucky_dice = True
|
|
|
|
|
participant.lucky_dice_multiplier = multiplier
|
|
|
|
|
|
|
|
|
|
# Log usage
|
|
|
|
|
usage = ConsumableUsage(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
item_id=item.id,
|
|
|
|
|
marathon_id=marathon.id,
|
|
|
|
|
effect_data={
|
|
|
|
|
"type": "lucky_dice",
|
|
|
|
|
"multiplier": multiplier,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(usage)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"lucky_dice_activated": True,
|
|
|
|
|
"multiplier": multiplier,
|
2026-01-05 07:15:50 +07:00
|
|
|
}
|
|
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
async def use_copycat(
|
2026-01-05 07:15:50 +07:00
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user: User,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
marathon: Marathon,
|
|
|
|
|
assignment: Assignment,
|
2026-01-08 08:49:51 +07:00
|
|
|
target_participant_id: int,
|
2026-01-05 07:15:50 +07:00
|
|
|
) -> dict:
|
|
|
|
|
"""
|
2026-01-08 08:49:51 +07:00
|
|
|
Use Copycat - copy another participant's assignment.
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
- Current assignment is replaced with target's current/last assignment
|
|
|
|
|
- Can copy even if target already completed theirs
|
|
|
|
|
- Cannot copy your own assignment
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
Returns: dict with copied assignment info
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
Raises:
|
2026-01-08 08:49:51 +07:00
|
|
|
HTTPException: If target not found or no assignment to copy
|
2026-01-05 07:15:50 +07:00
|
|
|
"""
|
|
|
|
|
if not marathon.allow_consumables:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
|
|
|
|
|
|
|
|
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
2026-01-08 08:49:51 +07:00
|
|
|
raise HTTPException(status_code=400, detail="Can only use copycat on active assignments")
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
if target_participant_id == participant.id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
# Find target participant
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Participant)
|
|
|
|
|
.where(
|
|
|
|
|
Participant.id == target_participant_id,
|
|
|
|
|
Participant.marathon_id == marathon.id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
target_participant = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not target_participant:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Target participant not found")
|
|
|
|
|
|
|
|
|
|
# Get target's most recent assignment (active or completed)
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Assignment)
|
|
|
|
|
.options(
|
|
|
|
|
selectinload(Assignment.challenge),
|
|
|
|
|
selectinload(Assignment.game).selectinload(Game.challenges),
|
|
|
|
|
)
|
|
|
|
|
.where(
|
|
|
|
|
Assignment.participant_id == target_participant_id,
|
|
|
|
|
Assignment.status.in_([
|
|
|
|
|
AssignmentStatus.ACTIVE.value,
|
|
|
|
|
AssignmentStatus.COMPLETED.value
|
|
|
|
|
])
|
|
|
|
|
)
|
|
|
|
|
.order_by(Assignment.started_at.desc())
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
target_assignment = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not target_assignment:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Target has no assignment to copy")
|
|
|
|
|
|
|
|
|
|
# Consume copycat from inventory
|
|
|
|
|
item = await self._consume_item(db, user, ConsumableType.COPYCAT.value)
|
|
|
|
|
|
|
|
|
|
# Store old assignment info for logging
|
2026-01-05 07:15:50 +07:00
|
|
|
old_game_id = assignment.game_id
|
2026-01-08 08:49:51 +07:00
|
|
|
old_challenge_id = assignment.challenge_id
|
|
|
|
|
old_is_playthrough = assignment.is_playthrough
|
|
|
|
|
|
|
|
|
|
# Copy the assignment - handle both challenge and playthrough
|
|
|
|
|
assignment.game_id = target_assignment.game_id
|
|
|
|
|
assignment.challenge_id = target_assignment.challenge_id
|
|
|
|
|
assignment.is_playthrough = target_assignment.is_playthrough
|
|
|
|
|
# Reset timestamps
|
|
|
|
|
assignment.started_at = datetime.utcnow()
|
|
|
|
|
assignment.deadline = None
|
|
|
|
|
|
|
|
|
|
# If copying a playthrough, recreate bonus assignments
|
|
|
|
|
if target_assignment.is_playthrough:
|
|
|
|
|
# Delete existing bonus assignments
|
|
|
|
|
for ba in assignment.bonus_assignments:
|
|
|
|
|
await db.delete(ba)
|
|
|
|
|
|
|
|
|
|
# Create new bonus assignments from target game's challenges
|
|
|
|
|
if target_assignment.game and target_assignment.game.challenges:
|
|
|
|
|
for ch in target_assignment.game.challenges:
|
|
|
|
|
bonus = BonusAssignment(
|
|
|
|
|
main_assignment_id=assignment.id,
|
|
|
|
|
challenge_id=ch.id,
|
|
|
|
|
)
|
|
|
|
|
db.add(bonus)
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
# Log usage
|
|
|
|
|
usage = ConsumableUsage(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
item_id=item.id,
|
|
|
|
|
marathon_id=marathon.id,
|
|
|
|
|
assignment_id=assignment.id,
|
|
|
|
|
effect_data={
|
2026-01-08 08:49:51 +07:00
|
|
|
"type": "copycat",
|
|
|
|
|
"old_challenge_id": old_challenge_id,
|
|
|
|
|
"old_game_id": old_game_id,
|
|
|
|
|
"old_is_playthrough": old_is_playthrough,
|
|
|
|
|
"copied_from_participant_id": target_participant_id,
|
|
|
|
|
"new_challenge_id": target_assignment.challenge_id,
|
|
|
|
|
"new_game_id": target_assignment.game_id,
|
|
|
|
|
"new_is_playthrough": target_assignment.is_playthrough,
|
2026-01-05 07:15:50 +07:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(usage)
|
|
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
# Prepare response
|
|
|
|
|
if target_assignment.is_playthrough:
|
|
|
|
|
title = f"Прохождение: {target_assignment.game.title}" if target_assignment.game else "Прохождение"
|
|
|
|
|
else:
|
|
|
|
|
title = target_assignment.challenge.title if target_assignment.challenge else None
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
return {
|
|
|
|
|
"success": True,
|
2026-01-08 08:49:51 +07:00
|
|
|
"copied": True,
|
|
|
|
|
"game_id": target_assignment.game_id,
|
|
|
|
|
"challenge_id": target_assignment.challenge_id,
|
|
|
|
|
"is_playthrough": target_assignment.is_playthrough,
|
|
|
|
|
"challenge_title": title,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def use_undo(
|
|
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user: User,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
marathon: Marathon,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Use Undo - restore points and streak from last drop.
|
|
|
|
|
|
|
|
|
|
- Only works if there was a drop in this marathon
|
|
|
|
|
- Can only undo once per drop
|
|
|
|
|
- Restores both points and streak
|
|
|
|
|
|
|
|
|
|
Returns: dict with restored values
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If no drop to undo
|
|
|
|
|
"""
|
|
|
|
|
if not marathon.allow_consumables:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
|
|
|
|
|
|
|
|
|
if not participant.can_undo:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No drop to undo")
|
|
|
|
|
|
|
|
|
|
if participant.last_drop_points is None or participant.last_drop_streak_before is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No drop data to restore")
|
|
|
|
|
|
|
|
|
|
# Consume undo from inventory
|
|
|
|
|
item = await self._consume_item(db, user, ConsumableType.UNDO.value)
|
|
|
|
|
|
|
|
|
|
# Store values for logging
|
|
|
|
|
points_restored = participant.last_drop_points
|
|
|
|
|
streak_restored = participant.last_drop_streak_before
|
|
|
|
|
current_points = participant.total_points
|
|
|
|
|
current_streak = participant.current_streak
|
|
|
|
|
|
|
|
|
|
# Restore points and streak
|
|
|
|
|
participant.total_points += points_restored
|
|
|
|
|
participant.current_streak = streak_restored
|
|
|
|
|
participant.drop_count = max(0, participant.drop_count - 1)
|
|
|
|
|
|
|
|
|
|
# Clear undo data
|
|
|
|
|
participant.can_undo = False
|
|
|
|
|
participant.last_drop_points = None
|
|
|
|
|
participant.last_drop_streak_before = None
|
|
|
|
|
|
|
|
|
|
# Log usage
|
|
|
|
|
usage = ConsumableUsage(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
item_id=item.id,
|
|
|
|
|
marathon_id=marathon.id,
|
|
|
|
|
effect_data={
|
|
|
|
|
"type": "undo",
|
|
|
|
|
"points_restored": points_restored,
|
|
|
|
|
"streak_restored_to": streak_restored,
|
|
|
|
|
"points_before": current_points,
|
|
|
|
|
"streak_before": current_streak,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(usage)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"undone": True,
|
|
|
|
|
"points_restored": points_restored,
|
|
|
|
|
"streak_restored": streak_restored,
|
|
|
|
|
"new_total_points": participant.total_points,
|
|
|
|
|
"new_streak": participant.current_streak,
|
2026-01-05 07:15:50 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def _consume_item(
|
|
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user: User,
|
|
|
|
|
item_code: str,
|
|
|
|
|
) -> ShopItem:
|
|
|
|
|
"""
|
|
|
|
|
Consume 1 unit of a consumable from user's inventory.
|
|
|
|
|
|
|
|
|
|
Returns: The consumed ShopItem
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If user doesn't have the item
|
|
|
|
|
"""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(UserInventory)
|
|
|
|
|
.options(selectinload(UserInventory.item))
|
|
|
|
|
.join(ShopItem)
|
|
|
|
|
.where(
|
|
|
|
|
UserInventory.user_id == user.id,
|
|
|
|
|
ShopItem.code == item_code,
|
|
|
|
|
UserInventory.quantity > 0,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
inv_item = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not inv_item:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"You don't have any {item_code} in your inventory"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Decrease quantity
|
|
|
|
|
inv_item.quantity -= 1
|
|
|
|
|
|
|
|
|
|
return inv_item.item
|
|
|
|
|
|
|
|
|
|
async def get_consumable_count(
|
|
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
user_id: int,
|
|
|
|
|
item_code: str,
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Get how many of a consumable user has"""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(UserInventory.quantity)
|
|
|
|
|
.join(ShopItem)
|
|
|
|
|
.where(
|
|
|
|
|
UserInventory.user_id == user_id,
|
|
|
|
|
ShopItem.code == item_code,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
quantity = result.scalar_one_or_none()
|
|
|
|
|
return quantity or 0
|
|
|
|
|
|
2026-01-08 06:51:15 +07:00
|
|
|
def consume_boost_on_complete(self, participant: Participant) -> float:
|
2026-01-05 07:15:50 +07:00
|
|
|
"""
|
2026-01-08 06:51:15 +07:00
|
|
|
Consume boost when completing assignment (called from wheel.py).
|
|
|
|
|
One-time use - boost is consumed after single complete.
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 06:51:15 +07:00
|
|
|
Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
|
2026-01-05 07:15:50 +07:00
|
|
|
"""
|
2026-01-08 06:51:15 +07:00
|
|
|
if participant.has_active_boost:
|
|
|
|
|
participant.has_active_boost = False
|
|
|
|
|
return self.BOOST_MULTIPLIER
|
|
|
|
|
return 1.0
|
2026-01-05 07:15:50 +07:00
|
|
|
|
2026-01-08 08:49:51 +07:00
|
|
|
def consume_lucky_dice_on_complete(self, participant: Participant) -> float:
|
|
|
|
|
"""
|
|
|
|
|
Consume lucky dice when completing assignment (called from wheel.py).
|
|
|
|
|
One-time use - consumed after single complete.
|
|
|
|
|
|
|
|
|
|
Returns: Multiplier value (rolled multiplier if active, 1.0 otherwise)
|
|
|
|
|
"""
|
|
|
|
|
if participant.has_lucky_dice and participant.lucky_dice_multiplier is not None:
|
|
|
|
|
multiplier = participant.lucky_dice_multiplier
|
|
|
|
|
participant.has_lucky_dice = False
|
|
|
|
|
participant.lucky_dice_multiplier = None
|
|
|
|
|
return multiplier
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
def save_drop_for_undo(
|
|
|
|
|
self,
|
|
|
|
|
participant: Participant,
|
|
|
|
|
points_lost: int,
|
|
|
|
|
streak_before: int,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""
|
|
|
|
|
Save drop data for potential undo (called from wheel.py before dropping).
|
|
|
|
|
"""
|
|
|
|
|
participant.last_drop_points = points_lost
|
|
|
|
|
participant.last_drop_streak_before = streak_before
|
|
|
|
|
participant.can_undo = True
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
# Singleton instance
|
|
|
|
|
consumables_service = ConsumablesService()
|