rework shop
This commit is contained in:
@@ -1,15 +1,25 @@
|
||||
"""
|
||||
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
|
||||
Consumables Service - handles consumable items usage
|
||||
|
||||
Consumables:
|
||||
- skip: Skip current assignment without penalty
|
||||
- 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
|
||||
"""
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import (
|
||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
||||
BonusAssignment
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +29,9 @@ class ConsumablesService:
|
||||
# Boost settings
|
||||
BOOST_MULTIPLIER = 1.5
|
||||
|
||||
# Lucky Dice multipliers (equal probability, starts from 1.5x)
|
||||
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
|
||||
|
||||
async def use_skip(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -85,53 +98,6 @@ class ConsumablesService:
|
||||
"streak_preserved": True,
|
||||
}
|
||||
|
||||
async def use_shield(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Shield - protects from next drop penalty.
|
||||
|
||||
- Next drop will not cause point penalty
|
||||
- Streak is preserved on next drop
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or shield already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_shield:
|
||||
raise HTTPException(status_code=400, detail="Shield is already active")
|
||||
|
||||
# Consume shield from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
|
||||
|
||||
# Activate shield
|
||||
participant.has_shield = True
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "shield",
|
||||
"activated": True,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"shield_activated": True,
|
||||
}
|
||||
|
||||
async def use_boost(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -140,10 +106,10 @@ class ConsumablesService:
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Boost - multiplies points for NEXT complete only.
|
||||
Activate a Boost - multiplies points for current assignment on complete.
|
||||
|
||||
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
|
||||
- One-time use (consumed on next complete)
|
||||
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
|
||||
- One-time use (consumed on complete)
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
@@ -181,41 +147,71 @@ class ConsumablesService:
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
}
|
||||
|
||||
async def use_reroll(
|
||||
async def use_wild_card(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
game_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use a Reroll - discard current assignment and spin again.
|
||||
Use Wild Card - choose a game and get a random challenge from it.
|
||||
|
||||
- Current assignment is cancelled (not dropped)
|
||||
- User can spin the wheel again
|
||||
- No penalty
|
||||
- Current assignment is replaced
|
||||
- New challenge is randomly selected from the chosen game
|
||||
- Game must be in the marathon
|
||||
|
||||
Returns: dict with result info
|
||||
Returns: dict with new assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or assignment not active
|
||||
HTTPException: If game not in marathon or no challenges available
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
|
||||
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
|
||||
|
||||
# Consume reroll from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
|
||||
# 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()
|
||||
|
||||
# Cancel current assignment
|
||||
old_challenge_id = assignment.challenge_id
|
||||
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
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
|
||||
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
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
@@ -224,17 +220,275 @@ class ConsumablesService:
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "reroll",
|
||||
"rerolled_from_challenge_id": old_challenge_id,
|
||||
"rerolled_from_game_id": old_game_id,
|
||||
"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,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"rerolled": True,
|
||||
"can_spin_again": True,
|
||||
"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,
|
||||
}
|
||||
|
||||
async def use_copycat(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
target_participant_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Copycat - copy another participant's assignment.
|
||||
|
||||
- Current assignment is replaced with target's current/last assignment
|
||||
- Can copy even if target already completed theirs
|
||||
- Cannot copy your own assignment
|
||||
|
||||
Returns: dict with copied assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If target not found or no assignment to copy
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only use copycat on active assignments")
|
||||
|
||||
if target_participant_id == participant.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
|
||||
|
||||
# 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
|
||||
old_game_id = assignment.game_id
|
||||
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)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"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,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
# 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
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"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,
|
||||
}
|
||||
|
||||
async def _consume_item(
|
||||
@@ -292,17 +546,6 @@ class ConsumablesService:
|
||||
quantity = result.scalar_one_or_none()
|
||||
return quantity or 0
|
||||
|
||||
def consume_shield(self, participant: Participant) -> bool:
|
||||
"""
|
||||
Consume shield when dropping (called from wheel.py).
|
||||
|
||||
Returns: True if shield was consumed, False otherwise
|
||||
"""
|
||||
if participant.has_shield:
|
||||
participant.has_shield = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def consume_boost_on_complete(self, participant: Participant) -> float:
|
||||
"""
|
||||
Consume boost when completing assignment (called from wheel.py).
|
||||
@@ -315,6 +558,33 @@ class ConsumablesService:
|
||||
return self.BOOST_MULTIPLIER
|
||||
return 1.0
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Singleton instance
|
||||
consumables_service = ConsumablesService()
|
||||
|
||||
Reference in New Issue
Block a user