Add shop
This commit is contained in:
288
backend/app/services/coins.py
Normal file
288
backend/app/services/coins.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Coins Service - handles all coin-related operations
|
||||
|
||||
Coins are earned only in certified marathons and can be spent in the shop.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
|
||||
from app.models.challenge import Difficulty
|
||||
|
||||
|
||||
class CoinsService:
|
||||
"""Service for managing coin transactions and balances"""
|
||||
|
||||
# Coins awarded per challenge difficulty (only in certified marathons)
|
||||
CHALLENGE_COINS = {
|
||||
Difficulty.EASY.value: 5,
|
||||
Difficulty.MEDIUM.value: 12,
|
||||
Difficulty.HARD.value: 25,
|
||||
}
|
||||
|
||||
# Coins for playthrough = points * this ratio
|
||||
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
|
||||
|
||||
# Coins awarded for marathon placements
|
||||
MARATHON_PLACE_COINS = {
|
||||
1: 100, # 1st place
|
||||
2: 50, # 2nd place
|
||||
3: 30, # 3rd place
|
||||
}
|
||||
|
||||
# Bonus coins for Common Enemy event winners
|
||||
COMMON_ENEMY_BONUS_COINS = {
|
||||
1: 15, # First to complete
|
||||
2: 10, # Second
|
||||
3: 5, # Third
|
||||
}
|
||||
|
||||
async def award_challenge_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
difficulty: str,
|
||||
assignment_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for completing a challenge.
|
||||
Only awards coins if marathon is certified.
|
||||
|
||||
Returns: number of coins awarded (0 if marathon not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.CHALLENGE_COINS.get(difficulty, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
# Create transaction
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.CHALLENGE_COMPLETE.value,
|
||||
reference_type="assignment",
|
||||
reference_id=assignment_id,
|
||||
description=f"Challenge completion ({difficulty})",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
# Update balances
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_playthrough_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
points: int,
|
||||
assignment_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for completing a playthrough.
|
||||
Coins = points * PLAYTHROUGH_COIN_RATIO
|
||||
|
||||
Returns: number of coins awarded (0 if marathon not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = int(points * self.PLAYTHROUGH_COIN_RATIO)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.PLAYTHROUGH_COMPLETE.value,
|
||||
reference_type="assignment",
|
||||
reference_id=assignment_id,
|
||||
description=f"Playthrough completion ({points} points)",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_marathon_place(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
marathon: Marathon,
|
||||
place: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for placing in a marathon (1st, 2nd, 3rd).
|
||||
|
||||
Returns: number of coins awarded (0 if not top 3 or not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.MARATHON_PLACE_COINS.get(place, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.MARATHON_PLACE.value,
|
||||
reference_type="marathon",
|
||||
reference_id=marathon.id,
|
||||
description=f"Marathon #{place} place: {marathon.title}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_common_enemy_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
rank: int,
|
||||
event_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award bonus coins for Common Enemy event completion.
|
||||
|
||||
Returns: number of bonus coins awarded
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.COMMON_ENEMY_BONUS_COINS.get(rank, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.COMMON_ENEMY_BONUS.value,
|
||||
reference_type="event",
|
||||
reference_id=event_id,
|
||||
description=f"Common Enemy #{rank} place",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def spend_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
description: str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Spend coins (for purchases).
|
||||
|
||||
Returns: True if successful, False if insufficient balance
|
||||
"""
|
||||
if user.coins_balance < amount:
|
||||
return False
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=-amount, # Negative for spending
|
||||
transaction_type=CoinTransactionType.PURCHASE.value,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
description=description,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance -= amount
|
||||
return True
|
||||
|
||||
async def refund_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
description: str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: int | None = None,
|
||||
) -> None:
|
||||
"""Refund coins to user (for failed purchases, etc.)"""
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=amount,
|
||||
transaction_type=CoinTransactionType.REFUND.value,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
description=description,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += amount
|
||||
|
||||
async def admin_grant_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
reason: str,
|
||||
admin_id: int,
|
||||
) -> None:
|
||||
"""Admin grants coins to user"""
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=amount,
|
||||
transaction_type=CoinTransactionType.ADMIN_GRANT.value,
|
||||
reference_type="admin",
|
||||
reference_id=admin_id,
|
||||
description=f"Admin grant: {reason}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += amount
|
||||
|
||||
async def admin_deduct_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
reason: str,
|
||||
admin_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Admin deducts coins from user.
|
||||
|
||||
Returns: True if successful, False if insufficient balance
|
||||
"""
|
||||
if user.coins_balance < amount:
|
||||
return False
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=-amount,
|
||||
transaction_type=CoinTransactionType.ADMIN_DEDUCT.value,
|
||||
reference_type="admin",
|
||||
reference_id=admin_id,
|
||||
description=f"Admin deduction: {reason}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance -= amount
|
||||
return True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
coins_service = CoinsService()
|
||||
323
backend/app/services/consumables.py
Normal file
323
backend/app/services/consumables.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import (
|
||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType
|
||||
)
|
||||
|
||||
|
||||
class ConsumablesService:
|
||||
"""Service for consumable items"""
|
||||
|
||||
# Boost settings
|
||||
BOOST_DURATION_HOURS = 2
|
||||
BOOST_MULTIPLIER = 1.5
|
||||
|
||||
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_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,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Boost - multiplies points for next 2 hours.
|
||||
|
||||
- Points for completed challenges are multiplied by BOOST_MULTIPLIER
|
||||
- Duration: BOOST_DURATION_HOURS
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or boost already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_active_boost:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Boost already active until {participant.active_boost_expires_at}"
|
||||
)
|
||||
|
||||
# Consume boost from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
|
||||
|
||||
# Activate boost
|
||||
participant.active_boost_multiplier = self.BOOST_MULTIPLIER
|
||||
participant.active_boost_expires_at = datetime.utcnow() + timedelta(hours=self.BOOST_DURATION_HOURS)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "boost",
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
"duration_hours": self.BOOST_DURATION_HOURS,
|
||||
"expires_at": participant.active_boost_expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"boost_activated": True,
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
"expires_at": participant.active_boost_expires_at,
|
||||
}
|
||||
|
||||
async def use_reroll(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
) -> dict:
|
||||
"""
|
||||
Use a Reroll - discard current assignment and spin again.
|
||||
|
||||
- Current assignment is cancelled (not dropped)
|
||||
- User can spin the wheel again
|
||||
- No penalty
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or assignment not active
|
||||
"""
|
||||
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")
|
||||
|
||||
# Consume reroll from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
|
||||
|
||||
# Cancel current assignment
|
||||
old_challenge_id = assignment.challenge_id
|
||||
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)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
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,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"rerolled": True,
|
||||
"can_spin_again": True,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
def consume_shield_on_drop(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 get_active_boost_multiplier(self, participant: Participant) -> float:
|
||||
"""
|
||||
Get current boost multiplier for participant.
|
||||
|
||||
Returns: Multiplier value (1.0 if no active boost)
|
||||
"""
|
||||
return participant.get_boost_multiplier()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
consumables_service = ConsumablesService()
|
||||
297
backend/app/services/shop.py
Normal file
297
backend/app/services/shop.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Shop Service - handles shop items, purchases, and inventory management
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import User, ShopItem, UserInventory, ShopItemType
|
||||
from app.services.coins import coins_service
|
||||
|
||||
|
||||
class ShopService:
|
||||
"""Service for shop operations"""
|
||||
|
||||
async def get_available_items(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
item_type: str | None = None,
|
||||
include_unavailable: bool = False,
|
||||
) -> list[ShopItem]:
|
||||
"""
|
||||
Get list of shop items.
|
||||
|
||||
Args:
|
||||
item_type: Filter by item type (frame, title, etc.)
|
||||
include_unavailable: Include inactive/out of stock items
|
||||
"""
|
||||
query = select(ShopItem)
|
||||
|
||||
if item_type:
|
||||
query = query.where(ShopItem.item_type == item_type)
|
||||
|
||||
if not include_unavailable:
|
||||
now = datetime.utcnow()
|
||||
query = query.where(
|
||||
ShopItem.is_active == True,
|
||||
(ShopItem.available_from.is_(None)) | (ShopItem.available_from <= now),
|
||||
(ShopItem.available_until.is_(None)) | (ShopItem.available_until >= now),
|
||||
(ShopItem.stock_remaining.is_(None)) | (ShopItem.stock_remaining > 0),
|
||||
)
|
||||
|
||||
query = query.order_by(ShopItem.price.asc())
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_item_by_id(self, db: AsyncSession, item_id: int) -> ShopItem | None:
|
||||
"""Get shop item by ID"""
|
||||
result = await db.execute(select(ShopItem).where(ShopItem.id == item_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_item_by_code(self, db: AsyncSession, code: str) -> ShopItem | None:
|
||||
"""Get shop item by code"""
|
||||
result = await db.execute(select(ShopItem).where(ShopItem.code == code))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def purchase_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_id: int,
|
||||
quantity: int = 1,
|
||||
) -> tuple[UserInventory, int]:
|
||||
"""
|
||||
Purchase an item from the shop.
|
||||
|
||||
Args:
|
||||
user: The purchasing user
|
||||
item_id: ID of item to purchase
|
||||
quantity: Number to purchase (only for consumables)
|
||||
|
||||
Returns:
|
||||
Tuple of (inventory item, total cost)
|
||||
|
||||
Raises:
|
||||
HTTPException: If item not found, not available, or insufficient funds
|
||||
"""
|
||||
# Get item
|
||||
item = await self.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Check availability
|
||||
if not item.is_available:
|
||||
raise HTTPException(status_code=400, detail="Item is not available")
|
||||
|
||||
# For non-consumables, quantity is always 1
|
||||
if item.item_type != ShopItemType.CONSUMABLE.value:
|
||||
quantity = 1
|
||||
|
||||
# Check if already owned
|
||||
existing = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.item_id == item.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="You already own this item")
|
||||
|
||||
# Check stock
|
||||
if item.stock_remaining is not None and item.stock_remaining < quantity:
|
||||
raise HTTPException(status_code=400, detail="Not enough stock available")
|
||||
|
||||
# Calculate total cost
|
||||
total_cost = item.price * quantity
|
||||
|
||||
# Check balance
|
||||
if user.coins_balance < total_cost:
|
||||
raise HTTPException(status_code=400, detail="Not enough coins")
|
||||
|
||||
# Deduct coins
|
||||
success = await coins_service.spend_coins(
|
||||
db, user, total_cost,
|
||||
f"Purchase: {item.name} x{quantity}",
|
||||
"shop_item", item.id,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Payment failed")
|
||||
|
||||
# Add to inventory
|
||||
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||
# For consumables, increase quantity if already exists
|
||||
existing_result = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.item_id == item.id,
|
||||
)
|
||||
)
|
||||
inv_item = existing_result.scalar_one_or_none()
|
||||
|
||||
if inv_item:
|
||||
inv_item.quantity += quantity
|
||||
else:
|
||||
inv_item = UserInventory(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
quantity=quantity,
|
||||
)
|
||||
db.add(inv_item)
|
||||
else:
|
||||
# For cosmetics, create new inventory entry
|
||||
inv_item = UserInventory(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
quantity=1,
|
||||
)
|
||||
db.add(inv_item)
|
||||
|
||||
# Decrease stock if limited
|
||||
if item.stock_remaining is not None:
|
||||
item.stock_remaining -= quantity
|
||||
|
||||
await db.flush()
|
||||
return inv_item, total_cost
|
||||
|
||||
async def get_user_inventory(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_type: str | None = None,
|
||||
) -> list[UserInventory]:
|
||||
"""Get user's inventory"""
|
||||
query = (
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.where(UserInventory.user_id == user_id)
|
||||
)
|
||||
|
||||
if item_type:
|
||||
query = query.join(ShopItem).where(ShopItem.item_type == item_type)
|
||||
|
||||
# Exclude empty consumables
|
||||
query = query.where(UserInventory.quantity > 0)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_inventory_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
inventory_id: int,
|
||||
) -> UserInventory | None:
|
||||
"""Get specific inventory item"""
|
||||
result = await db.execute(
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.where(
|
||||
UserInventory.id == inventory_id,
|
||||
UserInventory.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def equip_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
inventory_id: int,
|
||||
) -> ShopItem:
|
||||
"""
|
||||
Equip a cosmetic item from inventory.
|
||||
|
||||
Returns: The equipped item
|
||||
|
||||
Raises:
|
||||
HTTPException: If item not found or is a consumable
|
||||
"""
|
||||
# Get inventory item
|
||||
inv_item = await self.get_inventory_item(db, user.id, inventory_id)
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
item = inv_item.item
|
||||
|
||||
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot equip consumables")
|
||||
|
||||
# Unequip current item of same type
|
||||
await db.execute(
|
||||
update(UserInventory)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.equipped == True,
|
||||
UserInventory.item_id.in_(
|
||||
select(ShopItem.id).where(ShopItem.item_type == item.item_type)
|
||||
),
|
||||
)
|
||||
.values(equipped=False)
|
||||
)
|
||||
|
||||
# Equip new item
|
||||
inv_item.equipped = True
|
||||
|
||||
# Update user's equipped_*_id
|
||||
if item.item_type == ShopItemType.FRAME.value:
|
||||
user.equipped_frame_id = item.id
|
||||
elif item.item_type == ShopItemType.TITLE.value:
|
||||
user.equipped_title_id = item.id
|
||||
elif item.item_type == ShopItemType.NAME_COLOR.value:
|
||||
user.equipped_name_color_id = item.id
|
||||
elif item.item_type == ShopItemType.BACKGROUND.value:
|
||||
user.equipped_background_id = item.id
|
||||
|
||||
return item
|
||||
|
||||
async def unequip_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_type: str,
|
||||
) -> None:
|
||||
"""Unequip item of specified type"""
|
||||
# Unequip from inventory
|
||||
await db.execute(
|
||||
update(UserInventory)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.equipped == True,
|
||||
UserInventory.item_id.in_(
|
||||
select(ShopItem.id).where(ShopItem.item_type == item_type)
|
||||
),
|
||||
)
|
||||
.values(equipped=False)
|
||||
)
|
||||
|
||||
# Clear user's equipped_*_id
|
||||
if item_type == ShopItemType.FRAME.value:
|
||||
user.equipped_frame_id = None
|
||||
elif item_type == ShopItemType.TITLE.value:
|
||||
user.equipped_title_id = None
|
||||
elif item_type == ShopItemType.NAME_COLOR.value:
|
||||
user.equipped_name_color_id = None
|
||||
elif item_type == ShopItemType.BACKGROUND.value:
|
||||
user.equipped_background_id = None
|
||||
|
||||
async def check_user_owns_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_id: int,
|
||||
) -> bool:
|
||||
"""Check if user owns an item"""
|
||||
result = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user_id,
|
||||
UserInventory.item_id == item_id,
|
||||
UserInventory.quantity > 0,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
shop_service = ShopService()
|
||||
Reference in New Issue
Block a user