rework shop

This commit is contained in:
2026-01-08 08:49:51 +07:00
parent 2874b64481
commit 1751c4dd4c
16 changed files with 913 additions and 260 deletions

View File

@@ -181,18 +181,29 @@ async def use_consumable(
# Get participant
participant = await require_participant(db, current_user.id, data.marathon_id)
# For skip and reroll, we need the assignment
# For some consumables, we need the assignment
assignment = None
if data.item_code in ["skip", "reroll"]:
if data.item_code in ["skip", "wild_card", "copycat"]:
if not data.assignment_id:
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
# For copycat, we need bonus_assignments to properly handle playthrough
if data.item_code == "copycat":
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.bonus_assignments))
.where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
else:
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
@@ -201,15 +212,29 @@ async def use_consumable(
if data.item_code == "skip":
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped without penalty"
elif data.item_code == "shield":
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
effect_description = "Shield activated - next drop will be free"
elif data.item_code == "boost":
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
elif data.item_code == "reroll":
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
effect_description = "Assignment rerolled - you can spin again"
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
elif data.item_code == "wild_card":
if data.game_id is None:
raise HTTPException(status_code=400, detail="game_id is required for wild_card")
effect = await consumables_service.use_wild_card(
db, current_user, participant, marathon, assignment, data.game_id
)
effect_description = f"New challenge from {effect['game_name']}: {effect['challenge_title']}"
elif data.item_code == "lucky_dice":
effect = await consumables_service.use_lucky_dice(db, current_user, participant, marathon)
effect_description = f"Lucky Dice rolled: x{effect['multiplier']} multiplier"
elif data.item_code == "copycat":
if data.target_participant_id is None:
raise HTTPException(status_code=400, detail="target_participant_id is required for copycat")
effect = await consumables_service.use_copycat(
db, current_user, participant, marathon, assignment, data.target_participant_id
)
effect_description = f"Copied challenge: {effect['challenge_title']}"
elif data.item_code == "undo":
effect = await consumables_service.use_undo(db, current_user, participant, marathon)
effect_description = f"Restored {effect['points_restored']} points and streak {effect['streak_restored']}"
else:
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
@@ -243,9 +268,11 @@ async def get_consumables_status(
# Get inventory counts for all consumables
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
copycats_available = await consumables_service.get_consumable_count(db, current_user.id, "copycat")
undos_available = await consumables_service.get_consumable_count(db, current_user.id, "undo")
# Calculate remaining skips for this marathon
skips_remaining = None
@@ -256,12 +283,16 @@ async def get_consumables_status(
skips_available=skips_available,
skips_used=participant.skips_used,
skips_remaining=skips_remaining,
shields_available=shields_available,
has_shield=participant.has_shield,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost,
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
rerolls_available=rerolls_available,
wild_cards_available=wild_cards_available,
lucky_dice_available=lucky_dice_available,
has_lucky_dice=participant.has_lucky_dice,
lucky_dice_multiplier=participant.lucky_dice_multiplier,
copycats_available=copycats_available,
undos_available=undos_available,
can_undo=participant.can_undo,
)

View File

@@ -621,10 +621,12 @@ async def complete_assignment(
if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost multiplier from consumable
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -666,6 +668,8 @@ async def complete_assignment(
activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if playthrough_event:
@@ -728,10 +732,12 @@ async def complete_assignment(
total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost multiplier from consumable
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -772,6 +778,8 @@ async def complete_assignment(
activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if assignment.event_type == EventType.JACKPOT.value:
@@ -887,11 +895,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
participant.drop_count, game.playthrough_points, playthrough_event
)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -921,8 +928,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"penalty": penalty,
"lost_bonuses": completed_bonuses_count,
}
if shield_used:
activity_data["shield_used"] = True
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["free_drop"] = True
@@ -941,7 +946,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
shield_used=shield_used,
)
# Regular challenge drop
@@ -953,11 +957,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -975,8 +978,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"difficulty": assignment.challenge.difficulty,
"penalty": penalty,
}
if shield_used:
activity_data["shield_used"] = True
if active_event:
activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value:
@@ -996,7 +997,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
shield_used=shield_used,
)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -32,7 +32,15 @@ class Participant(Base):
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Lucky Dice state
has_lucky_dice: Mapped[bool] = mapped_column(Boolean, default=False)
lucky_dice_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
# Undo state - stores last drop data for potential rollback
last_drop_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_drop_streak_before: Mapped[int | None] = mapped_column(Integer, nullable=True)
can_undo: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="participations")

View File

@@ -28,9 +28,11 @@ class ItemRarity(str, Enum):
class ConsumableType(str, Enum):
SKIP = "skip"
SHIELD = "shield"
BOOST = "boost"
REROLL = "reroll"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"
COPYCAT = "copycat"
UNDO = "undo"
class ShopItem(Base):

View File

@@ -86,7 +86,6 @@ class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int
shield_used: bool = False # Whether shield consumable was used to prevent penalty
class EventAssignmentResponse(BaseModel):

View File

@@ -43,10 +43,10 @@ class ParticipantInfo(BaseModel):
# Shop: coins and consumables status
coins_earned: int = 0
skips_used: int = 0
has_shield: bool = False
has_active_boost: bool = False
boost_multiplier: float | None = None
boost_expires_at: datetime | None = None
has_lucky_dice: bool = False
lucky_dice_multiplier: float | None = None
can_undo: bool = False
class Config:
from_attributes = True

View File

@@ -94,9 +94,11 @@ class PurchaseResponse(BaseModel):
class UseConsumableRequest(BaseModel):
"""Schema for using a consumable"""
item_code: str # 'skip', 'shield', 'boost', 'reroll'
item_code: str # 'skip', 'boost', 'wild_card', 'lucky_dice', 'copycat', 'undo'
marathon_id: int
assignment_id: int | None = None # Required for skip and reroll
assignment_id: int | None = None # Required for skip, wild_card, copycat
game_id: int | None = None # Required for wild_card
target_participant_id: int | None = None # Required for copycat
class UseConsumableResponse(BaseModel):
@@ -192,9 +194,13 @@ class ConsumablesStatusResponse(BaseModel):
skips_available: int # From inventory
skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
shields_available: int # From inventory
has_shield: bool # Currently activated
boosts_available: int # From inventory
has_active_boost: bool # Currently activated (one-time for next complete)
has_active_boost: bool # Currently activated (one-time for current assignment)
boost_multiplier: float | None # 1.5 if boost active
rerolls_available: int # From inventory
wild_cards_available: int # From inventory
lucky_dice_available: int # From inventory
has_lucky_dice: bool # Currently activated
lucky_dice_multiplier: float | None # Rolled multiplier if active
copycats_available: int # From inventory
undos_available: int # From inventory
can_undo: bool # Has drop data to undo

View File

@@ -14,19 +14,19 @@ class CoinsService:
# Coins awarded per challenge difficulty (only in certified marathons)
CHALLENGE_COINS = {
Difficulty.EASY.value: 5,
Difficulty.MEDIUM.value: 12,
Difficulty.HARD.value: 25,
Difficulty.EASY.value: 10,
Difficulty.MEDIUM.value: 20,
Difficulty.HARD.value: 35,
}
# Coins for playthrough = points * this ratio
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
PLAYTHROUGH_COIN_RATIO = 0.10 # 10% of points
# Coins awarded for marathon placements
MARATHON_PLACE_COINS = {
1: 100, # 1st place
2: 50, # 2nd place
3: 30, # 3rd place
1: 500, # 1st place
2: 250, # 2nd place
3: 150, # 3rd place
}
# Bonus coins for Common Enemy event winners

View File

@@ -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()