Added new systems.
This commit is contained in:
@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import init_db
|
||||
from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets
|
||||
from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets, gamification
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@ -38,6 +38,7 @@ app.include_router(websocket.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(sport_events.router)
|
||||
app.include_router(spread_bets.router)
|
||||
app.include_router(gamification.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -5,6 +5,18 @@ from app.models.bet import Bet, BetProposal, BetCategory, BetStatus, BetVisibili
|
||||
from app.models.sport_event import SportEvent, SportType, EventStatus
|
||||
from app.models.spread_bet import SpreadBet, SpreadBetStatus, TeamSide
|
||||
from app.models.admin_settings import AdminSettings
|
||||
from app.models.gamification import (
|
||||
UserStats,
|
||||
Achievement,
|
||||
UserAchievement,
|
||||
LootBox,
|
||||
ActivityFeed,
|
||||
DailyReward,
|
||||
AchievementType,
|
||||
LootBoxRarity,
|
||||
LootBoxRewardType,
|
||||
TIER_CONFIG,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -26,4 +38,15 @@ __all__ = [
|
||||
"SpreadBetStatus",
|
||||
"TeamSide",
|
||||
"AdminSettings",
|
||||
# Gamification
|
||||
"UserStats",
|
||||
"Achievement",
|
||||
"UserAchievement",
|
||||
"LootBox",
|
||||
"ActivityFeed",
|
||||
"DailyReward",
|
||||
"AchievementType",
|
||||
"LootBoxRarity",
|
||||
"LootBoxRewardType",
|
||||
"TIER_CONFIG",
|
||||
]
|
||||
|
||||
216
backend/app/models/gamification.py
Normal file
216
backend/app/models/gamification.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""
|
||||
Gamification models for H2H betting platform
|
||||
Includes: Tiers, XP, Achievements, Loot Boxes, Streaks
|
||||
"""
|
||||
from sqlalchemy import String, DateTime, Enum, Float, Integer, ForeignKey, Boolean, Text, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AchievementType(enum.Enum):
|
||||
"""Types of achievements"""
|
||||
FIRST_BET = "first_bet"
|
||||
FIRST_WIN = "first_win"
|
||||
WIN_STREAK_3 = "win_streak_3"
|
||||
WIN_STREAK_5 = "win_streak_5"
|
||||
WIN_STREAK_10 = "win_streak_10"
|
||||
WHALE_BET = "whale_bet" # Single bet over $1000
|
||||
HIGH_ROLLER = "high_roller" # Total wagered over $10k
|
||||
CONSISTENT = "consistent" # Bet every day for a week
|
||||
UNDERDOG = "underdog" # Win 5 underdog bets
|
||||
SHARPSHOOTER = "sharpshooter" # 70%+ win rate with 20+ bets
|
||||
VETERAN = "veteran" # 100 total bets
|
||||
LEGEND = "legend" # 500 total bets
|
||||
PROFIT_MASTER = "profit_master" # $5000+ lifetime profit
|
||||
COMEBACK_KING = "comeback_king" # Win after 5 loss streak
|
||||
EARLY_BIRD = "early_bird" # Bet on event 24h+ before start
|
||||
SOCIAL_BUTTERFLY = "social_butterfly" # Bet against 10 different users
|
||||
TIER_UP = "tier_up" # Reach a new tier
|
||||
MAX_TIER = "max_tier" # Reach tier 10
|
||||
|
||||
|
||||
class LootBoxRarity(enum.Enum):
|
||||
"""Loot box rarities"""
|
||||
COMMON = "common"
|
||||
UNCOMMON = "uncommon"
|
||||
RARE = "rare"
|
||||
EPIC = "epic"
|
||||
LEGENDARY = "legendary"
|
||||
|
||||
|
||||
class LootBoxRewardType(enum.Enum):
|
||||
"""Types of rewards from loot boxes"""
|
||||
BONUS_CASH = "bonus_cash"
|
||||
XP_BOOST = "xp_boost"
|
||||
FEE_REDUCTION = "fee_reduction" # Temporary fee reduction
|
||||
FREE_BET = "free_bet"
|
||||
AVATAR_FRAME = "avatar_frame"
|
||||
BADGE = "badge"
|
||||
NOTHING = "nothing" # Bad luck!
|
||||
|
||||
|
||||
# Tier configuration: tier -> (min_xp, house_fee_percent, name)
|
||||
TIER_CONFIG = {
|
||||
0: (0, 10.0, "Bronze I"),
|
||||
1: (1000, 9.5, "Bronze II"),
|
||||
2: (3000, 9.0, "Bronze III"),
|
||||
3: (7000, 8.5, "Silver I"),
|
||||
4: (15000, 8.0, "Silver II"),
|
||||
5: (30000, 7.5, "Silver III"),
|
||||
6: (60000, 7.0, "Gold I"),
|
||||
7: (100000, 6.5, "Gold II"),
|
||||
8: (175000, 6.0, "Gold III"),
|
||||
9: (300000, 5.5, "Platinum"),
|
||||
10: (500000, 5.0, "Diamond"),
|
||||
}
|
||||
|
||||
|
||||
class UserStats(Base):
|
||||
"""Extended user statistics for gamification"""
|
||||
__tablename__ = "user_stats"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
|
||||
|
||||
# XP and Tier
|
||||
xp: Mapped[int] = mapped_column(Integer, default=0)
|
||||
tier: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Detailed Stats
|
||||
total_wagered: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
total_won: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
total_lost: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
net_profit: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
biggest_win: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
biggest_bet: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
# Streaks
|
||||
current_win_streak: Mapped[int] = mapped_column(Integer, default=0)
|
||||
current_loss_streak: Mapped[int] = mapped_column(Integer, default=0)
|
||||
best_win_streak: Mapped[int] = mapped_column(Integer, default=0)
|
||||
worst_loss_streak: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Activity
|
||||
bets_today: Mapped[int] = mapped_column(Integer, default=0)
|
||||
bets_this_week: Mapped[int] = mapped_column(Integer, default=0)
|
||||
bets_this_month: Mapped[int] = mapped_column(Integer, default=0)
|
||||
consecutive_days_betting: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_bet_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Opponents
|
||||
unique_opponents: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
user: Mapped["User"] = relationship(back_populates="stats")
|
||||
|
||||
def get_house_fee(self) -> float:
|
||||
"""Get the house fee percentage for this user's tier"""
|
||||
return TIER_CONFIG.get(self.tier, TIER_CONFIG[0])[1]
|
||||
|
||||
def get_tier_name(self) -> str:
|
||||
"""Get the display name for this user's tier"""
|
||||
return TIER_CONFIG.get(self.tier, TIER_CONFIG[0])[2]
|
||||
|
||||
def xp_to_next_tier(self) -> int:
|
||||
"""Get XP needed to reach the next tier"""
|
||||
if self.tier >= 10:
|
||||
return 0
|
||||
next_tier_xp = TIER_CONFIG[self.tier + 1][0]
|
||||
return max(0, next_tier_xp - self.xp)
|
||||
|
||||
def tier_progress_percent(self) -> float:
|
||||
"""Get progress percentage to next tier"""
|
||||
if self.tier >= 10:
|
||||
return 100.0
|
||||
current_tier_xp = TIER_CONFIG[self.tier][0]
|
||||
next_tier_xp = TIER_CONFIG[self.tier + 1][0]
|
||||
tier_xp_range = next_tier_xp - current_tier_xp
|
||||
xp_into_tier = self.xp - current_tier_xp
|
||||
return min(100.0, (xp_into_tier / tier_xp_range) * 100)
|
||||
|
||||
|
||||
class Achievement(Base):
|
||||
"""Achievement definitions"""
|
||||
__tablename__ = "achievements"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
type: Mapped[AchievementType] = mapped_column(Enum(AchievementType), unique=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
description: Mapped[str] = mapped_column(String(500))
|
||||
icon: Mapped[str] = mapped_column(String(50)) # Emoji or icon name
|
||||
xp_reward: Mapped[int] = mapped_column(Integer, default=100)
|
||||
rarity: Mapped[str] = mapped_column(String(20), default="common") # common, rare, epic, legendary
|
||||
|
||||
# Relationships
|
||||
user_achievements: Mapped[list["UserAchievement"]] = relationship(back_populates="achievement")
|
||||
|
||||
|
||||
class UserAchievement(Base):
|
||||
"""Tracks which achievements users have earned"""
|
||||
__tablename__ = "user_achievements"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
achievement_id: Mapped[int] = mapped_column(ForeignKey("achievements.id"))
|
||||
earned_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(back_populates="achievements")
|
||||
achievement: Mapped["Achievement"] = relationship(back_populates="user_achievements")
|
||||
|
||||
|
||||
class LootBox(Base):
|
||||
"""Loot box inventory for users"""
|
||||
__tablename__ = "loot_boxes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
rarity: Mapped[LootBoxRarity] = mapped_column(Enum(LootBoxRarity))
|
||||
source: Mapped[str] = mapped_column(String(50)) # How they got it: tier_up, achievement, daily, etc.
|
||||
opened: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
reward_type: Mapped[LootBoxRewardType | None] = mapped_column(Enum(LootBoxRewardType), nullable=True)
|
||||
reward_value: Mapped[str | None] = mapped_column(String(100), nullable=True) # JSON or simple value
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user: Mapped["User"] = relationship(back_populates="loot_boxes")
|
||||
|
||||
|
||||
class ActivityFeed(Base):
|
||||
"""Global activity feed for recent bets, wins, etc."""
|
||||
__tablename__ = "activity_feed"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
activity_type: Mapped[str] = mapped_column(String(50)) # bet_placed, bet_won, achievement, tier_up, whale_bet
|
||||
message: Mapped[str] = mapped_column(String(500))
|
||||
extra_data: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON for extra data
|
||||
amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
user: Mapped["User"] = relationship(back_populates="activities")
|
||||
|
||||
|
||||
class DailyReward(Base):
|
||||
"""Daily login rewards"""
|
||||
__tablename__ = "daily_rewards"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
day_streak: Mapped[int] = mapped_column(Integer, default=1)
|
||||
reward_type: Mapped[str] = mapped_column(String(50)) # xp, loot_box, bonus_cash
|
||||
reward_value: Mapped[str] = mapped_column(String(100))
|
||||
claimed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
user: Mapped["User"] = relationship(back_populates="daily_rewards")
|
||||
@ -44,3 +44,10 @@ class User(Base):
|
||||
transactions: Mapped[list["Transaction"]] = relationship(back_populates="user")
|
||||
created_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="creator", foreign_keys="SpreadBet.creator_id")
|
||||
taken_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="taker", foreign_keys="SpreadBet.taker_id")
|
||||
|
||||
# Gamification relationships
|
||||
stats: Mapped["UserStats"] = relationship(back_populates="user", uselist=False)
|
||||
achievements: Mapped[list["UserAchievement"]] = relationship(back_populates="user")
|
||||
loot_boxes: Mapped[list["LootBox"]] = relationship(back_populates="user")
|
||||
activities: Mapped[list["ActivityFeed"]] = relationship(back_populates="user")
|
||||
daily_rewards: Mapped[list["DailyReward"]] = relationship(back_populates="user")
|
||||
|
||||
723
backend/app/routers/gamification.py
Normal file
723
backend/app/routers/gamification.py
Normal 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")
|
||||
Reference in New Issue
Block a user