Files
game-marathon/backend/app/services/consumables.py

696 lines
23 KiB
Python
Raw Normal View History

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
- 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,
BonusAssignment, ExiledGame
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,
}
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-08 08:49:51 +07:00
Use Wild Card - choose a game and get a random challenge from it.
2026-01-05 07:15:50 +07:00
2026-01-08 08:49:51 +07:00
- Current assignment is replaced
- New challenge is randomly selected from the chosen game
- Game must be in the marathon
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-08 08:49:51 +07:00
# Verify game is in this marathon
result = await db.execute(
select(Game)
.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")
# Get random challenge from this game
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(func.random())
.limit(1)
)
new_challenge = result.scalar_one_or_none()
if not new_challenge:
raise HTTPException(status_code=400, detail="No challenges available for this game")
# Consume wild card from inventory
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
# Store old assignment info for logging
old_game_id = assignment.game_id
old_challenge_id = assignment.challenge_id
# Update assignment with new challenge
assignment.game_id = game_id
assignment.challenge_id = new_challenge.id
# Reset timestamps since it's a new challenge
assignment.started_at = datetime.utcnow()
assignment.deadline = None # Will be recalculated if needed
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,
"new_game_id": game_id,
"new_challenge_id": new_challenge.id,
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,
"game_name": game.name,
"challenge_id": new_challenge.id,
"challenge_title": new_challenge.title,
}
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()