Added new systems.

This commit is contained in:
2026-01-09 10:15:46 -06:00
parent 725b81494e
commit 267504e641
39 changed files with 4441 additions and 18 deletions

View File

@ -0,0 +1,723 @@
"""
Gamification API endpoints
Leaderboards, achievements, loot boxes, activity feed, etc.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, and_
from datetime import datetime, timedelta
from typing import Optional
import random
import json
from app.database import get_db
from app.models import (
User, UserStats, Achievement, UserAchievement, LootBox, ActivityFeed,
DailyReward, SpreadBet, SpreadBetStatus, AchievementType, LootBoxRarity,
LootBoxRewardType, TIER_CONFIG
)
from app.routers.auth import get_current_user
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1/gamification", tags=["gamification"])
# ============== Pydantic Schemas ==============
class UserStatsResponse(BaseModel):
user_id: int
username: str
display_name: Optional[str]
avatar_url: Optional[str]
xp: int
tier: int
tier_name: str
house_fee: float
xp_to_next_tier: int
tier_progress: float
total_wagered: float
total_won: float
net_profit: float
current_win_streak: int
best_win_streak: int
total_bets: int
wins: int
losses: int
win_rate: float
biggest_win: float
biggest_bet: float
class LeaderboardEntry(BaseModel):
rank: int
user_id: int
username: str
display_name: Optional[str]
avatar_url: Optional[str]
tier: int
tier_name: str
value: float # The metric being ranked (profit, wagered, etc.)
win_rate: float
total_bets: int
class AchievementResponse(BaseModel):
id: int
type: str
name: str
description: str
icon: str
xp_reward: int
rarity: str
earned: bool
earned_at: Optional[datetime]
class LootBoxResponse(BaseModel):
id: int
rarity: str
source: str
opened: bool
reward_type: Optional[str]
reward_value: Optional[str]
created_at: datetime
class LootBoxOpenResult(BaseModel):
reward_type: str
reward_value: str
message: str
xp_gained: int
class ActivityFeedEntry(BaseModel):
id: int
user_id: int
username: str
display_name: Optional[str]
activity_type: str
message: str
amount: Optional[float]
created_at: datetime
class DailyRewardResponse(BaseModel):
can_claim: bool
current_streak: int
reward_type: str
reward_value: str
next_claim_at: Optional[datetime]
class WhaleAlertEntry(BaseModel):
user_id: int
username: str
display_name: Optional[str]
bet_amount: float
event_name: str
spread: float
created_at: datetime
# ============== Helper Functions ==============
async def get_or_create_user_stats(db: AsyncSession, user_id: int) -> UserStats:
"""Get or create user stats record"""
result = await db.execute(select(UserStats).where(UserStats.user_id == user_id))
stats = result.scalar_one_or_none()
if not stats:
stats = UserStats(user_id=user_id)
db.add(stats)
await db.commit()
await db.refresh(stats)
return stats
def calculate_tier(xp: int) -> int:
"""Calculate tier based on XP"""
for tier in range(10, -1, -1):
if xp >= TIER_CONFIG[tier][0]:
return tier
return 0
async def add_activity(
db: AsyncSession,
user_id: int,
activity_type: str,
message: str,
amount: Optional[float] = None,
metadata: Optional[dict] = None
):
"""Add an entry to the activity feed"""
activity = ActivityFeed(
user_id=user_id,
activity_type=activity_type,
message=message,
amount=amount,
metadata=json.dumps(metadata) if metadata else None
)
db.add(activity)
await db.commit()
async def award_xp(db: AsyncSession, user_id: int, amount: int, reason: str) -> tuple[int, bool]:
"""Award XP to user, returns (new_xp, tier_changed)"""
stats = await get_or_create_user_stats(db, user_id)
old_tier = stats.tier
stats.xp += amount
new_tier = calculate_tier(stats.xp)
tier_changed = new_tier > old_tier
if tier_changed:
stats.tier = new_tier
# Award loot box for tier up
rarity = LootBoxRarity.COMMON if new_tier < 4 else (
LootBoxRarity.UNCOMMON if new_tier < 7 else (
LootBoxRarity.RARE if new_tier < 9 else LootBoxRarity.EPIC
)
)
loot_box = LootBox(user_id=user_id, rarity=rarity, source="tier_up")
db.add(loot_box)
await add_activity(
db, user_id, "tier_up",
f"Reached {TIER_CONFIG[new_tier][2]}! House fee now {TIER_CONFIG[new_tier][1]}%",
metadata={"old_tier": old_tier, "new_tier": new_tier}
)
await db.commit()
return stats.xp, tier_changed
# ============== Public Endpoints ==============
@router.get("/leaderboard/{category}")
async def get_leaderboard(
category: str,
limit: int = 20,
db: AsyncSession = Depends(get_db)
):
"""
Get leaderboard by category: profit, wagered, wins, win_rate, streak
"""
valid_categories = ["profit", "wagered", "wins", "win_rate", "streak"]
if category not in valid_categories:
raise HTTPException(400, f"Invalid category. Must be one of: {valid_categories}")
# Map category to column
order_column = {
"profit": UserStats.net_profit,
"wagered": UserStats.total_wagered,
"wins": User.wins,
"win_rate": User.win_rate,
"streak": UserStats.best_win_streak
}[category]
query = (
select(User, UserStats)
.outerjoin(UserStats, User.id == UserStats.user_id)
.order_by(desc(order_column))
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
leaderboard = []
for rank, (user, stats) in enumerate(rows, 1):
tier = stats.tier if stats else 0
value = {
"profit": stats.net_profit if stats else 0,
"wagered": stats.total_wagered if stats else 0,
"wins": user.wins,
"win_rate": user.win_rate,
"streak": stats.best_win_streak if stats else 0
}[category]
leaderboard.append(LeaderboardEntry(
rank=rank,
user_id=user.id,
username=user.username,
display_name=user.display_name,
avatar_url=user.avatar_url,
tier=tier,
tier_name=TIER_CONFIG[tier][2],
value=value,
win_rate=user.win_rate,
total_bets=user.total_bets
))
return leaderboard
@router.get("/whale-tracker")
async def get_whale_tracker(
limit: int = 10,
db: AsyncSession = Depends(get_db)
):
"""Get recent large bets (whale alerts)"""
# Get bets over $500 in the last 24 hours
whale_threshold = 500.0
since = datetime.utcnow() - timedelta(hours=24)
query = (
select(SpreadBet, User)
.join(User, SpreadBet.creator_id == User.id)
.where(
and_(
SpreadBet.stake_amount >= whale_threshold,
SpreadBet.created_at >= since
)
)
.order_by(desc(SpreadBet.stake_amount))
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
whales = []
for bet, user in rows:
whales.append(WhaleAlertEntry(
user_id=user.id,
username=user.username,
display_name=user.display_name,
bet_amount=bet.stake_amount,
event_name=f"Event #{bet.event_id}", # Would need join for full name
spread=bet.spread,
created_at=bet.created_at
))
return whales
@router.get("/activity-feed")
async def get_activity_feed(
limit: int = 20,
activity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""Get global activity feed"""
query = (
select(ActivityFeed, User)
.join(User, ActivityFeed.user_id == User.id)
.where(ActivityFeed.is_public == True)
)
if activity_type:
query = query.where(ActivityFeed.activity_type == activity_type)
query = query.order_by(desc(ActivityFeed.created_at)).limit(limit)
result = await db.execute(query)
rows = result.all()
activities = []
for activity, user in rows:
activities.append(ActivityFeedEntry(
id=activity.id,
user_id=user.id,
username=user.username,
display_name=user.display_name,
activity_type=activity.activity_type,
message=activity.message,
amount=activity.amount,
created_at=activity.created_at
))
return activities
@router.get("/recent-wins")
async def get_recent_wins(
limit: int = 10,
db: AsyncSession = Depends(get_db)
):
"""Get recent winning bets"""
query = (
select(SpreadBet, User)
.join(User, SpreadBet.winner_id == User.id)
.where(SpreadBet.status == SpreadBetStatus.COMPLETED)
.order_by(desc(SpreadBet.completed_at))
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
wins = []
for bet, user in rows:
payout = bet.stake_amount * 2 * 0.9 # Simplified payout calculation
wins.append({
"user_id": user.id,
"username": user.username,
"display_name": user.display_name,
"amount_won": payout,
"stake": bet.stake_amount,
"spread": bet.spread,
"completed_at": bet.completed_at
})
return wins
@router.get("/tier-info")
async def get_tier_info():
"""Get all tier information"""
tiers = []
for tier, (min_xp, fee, name) in TIER_CONFIG.items():
tiers.append({
"tier": tier,
"name": name,
"min_xp": min_xp,
"house_fee_percent": fee,
"benefits": get_tier_benefits(tier)
})
return tiers
def get_tier_benefits(tier: int) -> list[str]:
"""Get benefits for a tier"""
benefits = [f"{TIER_CONFIG[tier][1]}% house fee"]
if tier >= 3:
benefits.append("Priority support")
if tier >= 5:
benefits.append("Weekly loot box")
if tier >= 7:
benefits.append("Exclusive events access")
if tier >= 9:
benefits.append("VIP Discord channel")
if tier == 10:
benefits.append("Custom avatar frame")
benefits.append("Diamond badge")
return benefits
# ============== Authenticated Endpoints ==============
@router.get("/my-stats")
async def get_my_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get current user's gamification stats"""
stats = await get_or_create_user_stats(db, current_user.id)
return UserStatsResponse(
user_id=current_user.id,
username=current_user.username,
display_name=current_user.display_name,
avatar_url=current_user.avatar_url,
xp=stats.xp,
tier=stats.tier,
tier_name=TIER_CONFIG[stats.tier][2],
house_fee=TIER_CONFIG[stats.tier][1],
xp_to_next_tier=stats.xp_to_next_tier(),
tier_progress=stats.tier_progress_percent(),
total_wagered=stats.total_wagered,
total_won=stats.total_won,
net_profit=stats.net_profit,
current_win_streak=stats.current_win_streak,
best_win_streak=stats.best_win_streak,
total_bets=current_user.total_bets,
wins=current_user.wins,
losses=current_user.losses,
win_rate=current_user.win_rate,
biggest_win=stats.biggest_win,
biggest_bet=stats.biggest_bet
)
@router.get("/my-achievements")
async def get_my_achievements(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get user's achievements (earned and unearned)"""
# Get all achievements
all_achievements = await db.execute(select(Achievement))
achievements = all_achievements.scalars().all()
# Get user's earned achievements
earned_query = await db.execute(
select(UserAchievement).where(UserAchievement.user_id == current_user.id)
)
earned = {ua.achievement_id: ua.earned_at for ua in earned_query.scalars().all()}
result = []
for ach in achievements:
result.append(AchievementResponse(
id=ach.id,
type=ach.type.value,
name=ach.name,
description=ach.description,
icon=ach.icon,
xp_reward=ach.xp_reward,
rarity=ach.rarity,
earned=ach.id in earned,
earned_at=earned.get(ach.id)
))
return result
@router.get("/my-loot-boxes")
async def get_my_loot_boxes(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get user's loot boxes"""
query = await db.execute(
select(LootBox)
.where(LootBox.user_id == current_user.id)
.order_by(desc(LootBox.created_at))
)
loot_boxes = query.scalars().all()
return [
LootBoxResponse(
id=lb.id,
rarity=lb.rarity.value,
source=lb.source,
opened=lb.opened,
reward_type=lb.reward_type.value if lb.reward_type else None,
reward_value=lb.reward_value,
created_at=lb.created_at
)
for lb in loot_boxes
]
@router.post("/open-loot-box/{loot_box_id}")
async def open_loot_box(
loot_box_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Open a loot box"""
result = await db.execute(
select(LootBox).where(
and_(LootBox.id == loot_box_id, LootBox.user_id == current_user.id)
)
)
loot_box = result.scalar_one_or_none()
if not loot_box:
raise HTTPException(404, "Loot box not found")
if loot_box.opened:
raise HTTPException(400, "Loot box already opened")
# Determine reward based on rarity
reward = generate_loot_box_reward(loot_box.rarity)
loot_box.opened = True
loot_box.opened_at = datetime.utcnow()
loot_box.reward_type = reward["type"]
loot_box.reward_value = reward["value"]
# Apply reward
xp_gained = apply_loot_box_reward(reward, current_user.id, db)
await db.commit()
await add_activity(
db, current_user.id, "loot_box_opened",
f"Opened a {loot_box.rarity.value} loot box and got {reward['message']}!",
metadata={"rarity": loot_box.rarity.value, "reward": reward}
)
return LootBoxOpenResult(
reward_type=reward["type"].value,
reward_value=reward["value"],
message=reward["message"],
xp_gained=xp_gained
)
def generate_loot_box_reward(rarity: LootBoxRarity) -> dict:
"""Generate a random reward based on loot box rarity"""
# Rarity affects reward quality
multiplier = {
LootBoxRarity.COMMON: 1,
LootBoxRarity.UNCOMMON: 2,
LootBoxRarity.RARE: 4,
LootBoxRarity.EPIC: 8,
LootBoxRarity.LEGENDARY: 16
}[rarity]
# Random reward type with weighted probabilities
roll = random.random()
if roll < 0.05: # 5% nothing
return {
"type": LootBoxRewardType.NOTHING,
"value": "0",
"message": "Better luck next time!"
}
elif roll < 0.35: # 30% XP boost
xp = random.randint(50, 200) * multiplier
return {
"type": LootBoxRewardType.XP_BOOST,
"value": str(xp),
"message": f"+{xp} XP"
}
elif roll < 0.55: # 20% bonus cash
cash = random.randint(5, 25) * multiplier
return {
"type": LootBoxRewardType.BONUS_CASH,
"value": str(cash),
"message": f"${cash} bonus cash"
}
elif roll < 0.75: # 20% fee reduction
hours = random.randint(1, 6) * multiplier
reduction = random.choice([1, 2, 3])
return {
"type": LootBoxRewardType.FEE_REDUCTION,
"value": json.dumps({"hours": hours, "reduction": reduction}),
"message": f"{reduction}% fee reduction for {hours} hours"
}
elif roll < 0.90: # 15% badge
badges = ["fire", "star", "crown", "diamond", "rocket", "trophy"]
badge = random.choice(badges)
return {
"type": LootBoxRewardType.BADGE,
"value": badge,
"message": f"'{badge.title()}' badge"
}
else: # 10% free bet
amount = random.randint(10, 50) * multiplier
return {
"type": LootBoxRewardType.FREE_BET,
"value": str(amount),
"message": f"${amount} free bet"
}
def apply_loot_box_reward(reward: dict, user_id: int, db: AsyncSession) -> int:
"""Apply loot box reward to user, returns XP gained"""
reward_type = reward["type"]
if reward_type == LootBoxRewardType.XP_BOOST:
return int(reward["value"])
elif reward_type == LootBoxRewardType.BONUS_CASH:
# Would need to add to wallet - simplified for now
return 25
elif reward_type == LootBoxRewardType.FREE_BET:
return 50
elif reward_type == LootBoxRewardType.BADGE:
return 100
elif reward_type == LootBoxRewardType.FEE_REDUCTION:
return 75
return 0
@router.get("/daily-reward")
async def check_daily_reward(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Check if user can claim daily reward"""
# Get most recent daily reward
result = await db.execute(
select(DailyReward)
.where(DailyReward.user_id == current_user.id)
.order_by(desc(DailyReward.claimed_at))
.limit(1)
)
last_reward = result.scalar_one_or_none()
now = datetime.utcnow()
can_claim = True
current_streak = 1
next_claim_at = None
if last_reward:
hours_since = (now - last_reward.claimed_at).total_seconds() / 3600
if hours_since < 24:
can_claim = False
next_claim_at = last_reward.claimed_at + timedelta(hours=24)
if hours_since < 48:
current_streak = last_reward.day_streak + 1
else:
current_streak = 1 # Reset streak
# Determine reward based on streak
reward_type, reward_value = get_daily_reward_for_streak(current_streak)
return DailyRewardResponse(
can_claim=can_claim,
current_streak=current_streak,
reward_type=reward_type,
reward_value=reward_value,
next_claim_at=next_claim_at
)
@router.post("/claim-daily-reward")
async def claim_daily_reward(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Claim daily login reward"""
# Check if can claim
check = await check_daily_reward(current_user, db)
if not check.can_claim:
raise HTTPException(400, "Cannot claim yet. Come back later!")
# Create daily reward record
reward = DailyReward(
user_id=current_user.id,
day_streak=check.current_streak,
reward_type=check.reward_type,
reward_value=check.reward_value
)
db.add(reward)
# Award XP
xp_amount = 50 + (check.current_streak * 10)
await award_xp(db, current_user.id, xp_amount, "daily_reward")
# Every 7 days give a loot box
if check.current_streak % 7 == 0:
rarity = LootBoxRarity.UNCOMMON if check.current_streak < 14 else (
LootBoxRarity.RARE if check.current_streak < 30 else LootBoxRarity.EPIC
)
loot_box = LootBox(user_id=current_user.id, rarity=rarity, source="daily_streak")
db.add(loot_box)
await db.commit()
await add_activity(
db, current_user.id, "daily_reward",
f"Claimed day {check.current_streak} reward: {check.reward_value}",
metadata={"streak": check.current_streak}
)
return {
"success": True,
"streak": check.current_streak,
"reward_type": check.reward_type,
"reward_value": check.reward_value,
"xp_gained": xp_amount,
"bonus_loot_box": check.current_streak % 7 == 0
}
def get_daily_reward_for_streak(streak: int) -> tuple[str, str]:
"""Get daily reward based on streak day"""
if streak % 7 == 0:
return ("loot_box", "Loot Box + 100 XP")
elif streak % 3 == 0:
return ("bonus_xp", f"{50 + streak * 10} XP")
else:
return ("xp", f"{25 + streak * 5} XP")