evg_ultimate_team / backend /app /services /pack_service.py
clementpep's picture
feat: add credits logic
2477176
"""
Pack service for EVG Ultimate Team.
Handles all business logic for pack opening, rewards, and inventory management.
"""
from sqlalchemy.orm import Session
from sqlalchemy import and_
from typing import Optional, Tuple
import random
from datetime import datetime
from app.models.participant import Participant
from app.models.pack_reward import PackReward
from app.models.pack_opening import PackOpening
from app.schemas.pack import (
PackInventoryResponse,
PackRewardResponse,
PackOpenResponse,
PackHistoryItem,
PackCostsResponse
)
from app.utils.logger import logger
# =============================================================================
# Pack Configuration
# =============================================================================
PACK_COSTS = {
"bronze": 100,
"silver": 200,
"gold": 300,
"ultimate": 500,
}
RARITY_WEIGHTS = {
"bronze": {"common": 100}, # 100% common
"silver": {"common": 60, "rare": 40}, # 60% common, 40% rare
"gold": {"rare": 50, "epic": 50}, # 50% rare, 50% epic
"ultimate": {"epic": 30, "legendary": 70}, # 30% epic, 70% legendary
}
ANIMATION_DURATIONS = {
"common": 7, # 7 seconds total
"rare": 8,
"epic": 9,
"legendary": 10,
}
# =============================================================================
# Pack Costs
# =============================================================================
def get_pack_costs() -> PackCostsResponse:
"""
Get pack costs for all tiers.
Returns:
PackCostsResponse with costs for each tier
Example:
>>> costs = get_pack_costs()
>>> print(costs.bronze)
100
"""
return PackCostsResponse(**PACK_COSTS)
# =============================================================================
# Pack Inventory
# =============================================================================
def get_pack_inventory(db: Session, participant_id: int) -> PackInventoryResponse:
"""
Get participant's current pack inventory.
Args:
db: Database session
participant_id: ID of the participant
Returns:
PackInventoryResponse with pack counts
Raises:
ValueError: If participant not found
Example:
>>> inventory = get_pack_inventory(db, 1)
>>> print(inventory.bronze)
3
"""
participant = db.query(Participant).filter(Participant.id == participant_id).first()
if not participant:
raise ValueError(f"Participant with ID {participant_id} not found")
# Return current pack inventory - convert to dict if needed
packs = dict(participant.current_packs) if participant.current_packs else {"bronze": 0, "silver": 0, "gold": 0, "ultimate": 0}
return PackInventoryResponse(**packs)
# =============================================================================
# Pack Validation
# =============================================================================
def can_open_pack(db: Session, participant_id: int, tier: str) -> Tuple[bool, str]:
"""
Check if participant can open a pack of the specified tier.
Args:
db: Database session
participant_id: ID of the participant
tier: Pack tier (bronze/silver/gold/ultimate)
Returns:
Tuple of (can_open: bool, reason: str)
Example:
>>> can_open, reason = can_open_pack(db, 1, "bronze")
>>> if not can_open:
>>> print(reason)
"""
# Validate tier
if tier not in PACK_COSTS:
return False, f"Invalid pack tier: {tier}"
# Get participant
participant = db.query(Participant).filter(Participant.id == participant_id).first()
if not participant:
return False, f"Participant with ID {participant_id} not found"
# Check if participant has packs of this tier
pack_count = participant.current_packs.get(tier, 0)
if pack_count <= 0:
return False, f"No {tier} packs available"
return True, "Pack can be opened"
# =============================================================================
# Pack Rewards
# =============================================================================
def get_rewards_by_tier(db: Session, tier: str) -> list[PackReward]:
"""
Get all possible rewards for a pack tier.
Args:
db: Database session
tier: Pack tier (bronze/silver/gold/ultimate)
Returns:
List of PackReward objects
Example:
>>> rewards = get_rewards_by_tier(db, "bronze")
>>> for reward in rewards:
>>> print(reward.reward_name)
"""
return db.query(PackReward).filter(
and_(
PackReward.tier == tier,
PackReward.is_active == True
)
).all()
def select_random_reward(db: Session, tier: str) -> PackReward:
"""
Select a random reward from a pack tier based on rarity weights.
Args:
db: Database session
tier: Pack tier (bronze/silver/gold/ultimate)
Returns:
Selected PackReward
Raises:
ValueError: If no rewards found for tier
Example:
>>> reward = select_random_reward(db, "bronze")
>>> print(reward.reward_name, reward.rarity)
"""
# Get rarity weights for this tier
weights = RARITY_WEIGHTS.get(tier, {"common": 100})
# Select rarity based on weights
rarities = list(weights.keys())
probabilities = list(weights.values())
selected_rarity = random.choices(rarities, weights=probabilities, k=1)[0]
# Get all rewards of this rarity for this tier
rewards = db.query(PackReward).filter(
and_(
PackReward.tier == tier,
PackReward.rarity == selected_rarity,
PackReward.is_active == True
)
).all()
if not rewards:
logger.error(f"No rewards found for tier={tier}, rarity={selected_rarity}")
# Fallback to any reward for this tier
rewards = get_rewards_by_tier(db, tier)
if not rewards:
raise ValueError(f"No rewards available for tier: {tier}")
# Select random reward from filtered list
return random.choice(rewards)
# =============================================================================
# Pack Opening
# =============================================================================
def open_pack(db: Session, participant_id: int, tier: str) -> PackOpenResponse:
"""
Open a pack and return the reward.
This is the main pack opening logic that:
1. Validates pack availability
2. Selects a random reward
3. Removes pack from inventory
4. Records opening in history
5. Returns reward with animation metadata
Args:
db: Database session
participant_id: ID of the participant
tier: Pack tier to open (bronze/silver/gold/ultimate)
Returns:
PackOpenResponse with reward and updated inventory
Raises:
ValueError: If pack cannot be opened (no inventory, invalid tier, etc.)
Example:
>>> result = open_pack(db, 1, "bronze")
>>> print(result.reward.name)
>>> print(result.new_inventory.bronze)
"""
# Validate pack can be opened
can_open, reason = can_open_pack(db, participant_id, tier)
if not can_open:
logger.warning(f"Pack opening failed for participant {participant_id}: {reason}")
raise ValueError(reason)
# Get participant
participant = db.query(Participant).filter(Participant.id == participant_id).first()
# Select random reward
reward = select_random_reward(db, tier)
logger.info(f"Selected reward: {reward.reward_name} (rarity: {reward.rarity}) for participant {participant_id}")
# Remove pack from inventory (raises ValueError if pack not available)
participant.remove_pack(tier)
# Record pack opening in history
pack_opening = PackOpening(
participant_id=participant_id,
reward_id=reward.id,
pack_tier=tier,
points_spent=0, # Free pack (points not deducted)
opened_at=datetime.utcnow()
)
db.add(pack_opening)
# Commit changes
db.commit()
db.refresh(participant)
logger.info(f"Pack opened successfully: participant={participant_id}, tier={tier}, reward={reward.reward_name}")
# Build response
reward_response = PackRewardResponse(
id=reward.id,
name=reward.reward_name,
description=reward.reward_description,
type=reward.reward_type,
rarity=reward.rarity
)
new_inventory = PackInventoryResponse(**dict(participant.current_packs))
animation_data = {
"duration": ANIMATION_DURATIONS.get(reward.rarity, 7),
"rarity": reward.rarity,
"effects": _get_animation_effects(reward.rarity)
}
return PackOpenResponse(
success=True,
reward=reward_response,
new_inventory=new_inventory,
animation_data=animation_data
)
def _get_animation_effects(rarity: str) -> list[str]:
"""
Get animation effects based on reward rarity.
Args:
rarity: Reward rarity (common/rare/epic/legendary)
Returns:
List of effect names
"""
effects_map = {
"common": ["pulse", "fade"],
"rare": ["pulse", "particles", "glow"],
"epic": ["pulse", "particles", "glow", "shake"],
"legendary": ["pulse", "particles", "glow", "shake", "confetti", "screen_shake"],
}
return effects_map.get(rarity, ["pulse"])
# =============================================================================
# Pack Distribution
# =============================================================================
def purchase_pack(db: Session, participant_id: int, tier: str) -> None:
"""
Purchase a pack using pack credits.
Deducts credits from participant and adds pack to inventory.
Note: This does NOT affect total_points, which never decrease.
Args:
db: Database session
participant_id: ID of the participant
tier: Pack tier to purchase (bronze/silver/gold/ultimate)
Raises:
ValueError: If participant not found, invalid tier, or insufficient credits
Example:
>>> purchase_pack(db, 1, "bronze")
>>> # Deducts 100 credits and adds 1 bronze pack
"""
participant = db.query(Participant).filter(Participant.id == participant_id).first()
if not participant:
raise ValueError(f"Participant with ID {participant_id} not found")
if tier not in PACK_COSTS:
raise ValueError(f"Invalid pack tier: {tier}")
cost = PACK_COSTS[tier]
# Check if participant has enough credits
if participant.pack_credits < cost:
raise ValueError(f"Insufficient credits. Need {cost} credits, have {participant.pack_credits}")
# Deduct credits (NOT points - points never decrease)
participant.subtract_credits(cost)
# Add pack to inventory
participant.add_pack(tier)
db.commit()
logger.info(f"Participant {participant_id} purchased {tier} pack for {cost} credits")
def add_free_pack(db: Session, participant_id: int, tier: str, count: int = 1) -> None:
"""
Add free pack(s) to participant's inventory.
Args:
db: Database session
participant_id: ID of the participant
tier: Pack tier to add (bronze/silver/gold/ultimate)
count: Number of packs to add (default: 1)
Raises:
ValueError: If participant not found or invalid tier
Example:
>>> add_free_pack(db, 1, "bronze", 2)
>>> # Adds 2 bronze packs to participant 1
"""
participant = db.query(Participant).filter(Participant.id == participant_id).first()
if not participant:
raise ValueError(f"Participant with ID {participant_id} not found")
if tier not in PACK_COSTS:
raise ValueError(f"Invalid pack tier: {tier}")
# Add packs to inventory
for _ in range(count):
participant.add_pack(tier)
db.commit()
logger.info(f"Added {count} {tier} pack(s) to participant {participant_id}")
def distribute_free_packs_to_all(db: Session, pack_distribution: dict) -> int:
"""
Distribute free packs to all participants.
Args:
db: Database session
pack_distribution: Dictionary mapping tier to count
Example: {"bronze": 2, "silver": 1}
Returns:
Number of participants who received packs
Example:
>>> count = distribute_free_packs_to_all(db, {"bronze": 2})
>>> print(f"Distributed to {count} participants")
"""
participants = db.query(Participant).all()
for participant in participants:
for tier, count in pack_distribution.items():
if tier not in PACK_COSTS:
logger.warning(f"Invalid tier '{tier}' in distribution, skipping")
continue
for _ in range(count):
participant.add_pack(tier)
db.commit()
logger.info(f"Distributed {pack_distribution} to {len(participants)} participants")
return len(participants)
# =============================================================================
# Pack History
# =============================================================================
def get_pack_history(db: Session, participant_id: int, limit: int = 50) -> list[PackHistoryItem]:
"""
Get participant's pack opening history.
Args:
db: Database session
participant_id: ID of the participant
limit: Maximum number of items to return (default: 50)
Returns:
List of PackHistoryItem objects
Example:
>>> history = get_pack_history(db, 1, limit=10)
>>> for item in history:
>>> print(item.reward_name, item.opened_at)
"""
openings = db.query(PackOpening).filter(
PackOpening.participant_id == participant_id
).order_by(PackOpening.opened_at.desc()).limit(limit).all()
return [
PackHistoryItem(
id=opening.id,
pack_tier=opening.pack_tier,
reward_name=opening.reward.reward_name,
reward_description=opening.reward.reward_description,
opened_at=opening.opened_at,
points_spent=opening.points_spent
)
for opening in openings
]