Files
vyryi-back/app/api/routes.py
Redsandy 5125b52583 Add spin session functionality and update game mechanics
- Introduced a new `spin_sessions` table to track user spin activities.
- Updated the API to include a new endpoint for spinning games, allowing users to place bets and specify spin counts.
- Enhanced game information structure and added validation for bets and spin counts.
- Implemented the `Forest Fortune` slot game mechanics, including payline evaluation and scatter functionality.
- Updated dependencies in `pyproject.toml` for development and added `httpx` for HTTP requests.
- Added unit tests for the new slot mechanics and API endpoints to ensure functionality and reliability.
2026-08-12 15:27:24 +03:00

274 lines
8.4 KiB
Python

import json
from datetime import datetime, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import create_access_token, decode_access_token
from app.models import Deposit, SpinSession, User
from app.schemas import (
DepositRequest,
DepositResponse,
DevLoginRequest,
GameInfo,
PaylineWinResponse,
SessionSpinResponse,
SpinOutcomeResponse,
SpinRequest,
TelegramAuthRequest,
TokenResponse,
UserResponse,
WalletResponse,
)
from app.slots.registry import get_slot, list_slots
router = APIRouter()
security = HTTPBearer()
ALLOWED_DEPOSIT_AMOUNTS = {Decimal("100"), Decimal("500"), Decimal("1000")}
def user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
balance=user.balance,
agreement_accepted=user.agreement_accepted_at is not None,
agreement_accepted_at=user.agreement_accepted_at,
created_at=user.created_at,
)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
user_id = decode_access_token(credentials.credentials)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def slot_to_info(slot) -> GameInfo:
data = slot.info()
return GameInfo(**data)
@router.post("/auth/dev-login", response_model=TokenResponse)
def dev_login(payload: DevLoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = db.execute(select(User).where(User.telegram_id == payload.telegram_id)).scalar_one_or_none()
if user is None:
user = User(
telegram_id=payload.telegram_id,
username=payload.username,
first_name=payload.first_name,
balance=Decimal("0.00"),
)
db.add(user)
db.commit()
db.refresh(user)
else:
user.username = payload.username
user.first_name = payload.first_name
db.commit()
db.refresh(user)
token = create_access_token(str(user.id))
return TokenResponse(access_token=token)
@router.post("/auth/telegram", response_model=TokenResponse)
def telegram_auth(_payload: TelegramAuthRequest) -> TokenResponse:
# BOT_TOKEN not configured yet — wire real initData validation later.
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Telegram auth requires BOT_TOKEN. Use /auth/dev-login for local development.",
)
@router.get("/me", response_model=UserResponse)
def get_me(user: User = Depends(get_current_user)) -> UserResponse:
return user_to_response(user)
@router.post("/me/agreement", response_model=UserResponse)
def accept_agreement(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UserResponse:
if user.agreement_accepted_at is None:
user.agreement_accepted_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
return user_to_response(user)
@router.get("/wallet", response_model=WalletResponse)
def get_wallet(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> WalletResponse:
deposits = (
db.execute(
select(Deposit).where(Deposit.user_id == user.id).order_by(Deposit.created_at.desc()).limit(20)
)
.scalars()
.all()
)
return WalletResponse(
balance=user.balance,
deposits=[DepositResponse.model_validate(d) for d in deposits],
)
@router.post("/wallet/deposit", response_model=WalletResponse)
def deposit_stub(
payload: DepositRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> WalletResponse:
if user.agreement_accepted_at is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accept user agreement first",
)
amount = payload.amount.quantize(Decimal("0.01"))
if amount not in ALLOWED_DEPOSIT_AMOUNTS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Amount must be one of: {sorted(ALLOWED_DEPOSIT_AMOUNTS)}",
)
deposit = Deposit(user_id=user.id, amount=amount)
user.balance = (user.balance or Decimal("0.00")) + amount
db.add(deposit)
db.commit()
db.refresh(user)
deposits = (
db.execute(
select(Deposit).where(Deposit.user_id == user.id).order_by(Deposit.created_at.desc()).limit(20)
)
.scalars()
.all()
)
return WalletResponse(
balance=user.balance,
deposits=[DepositResponse.model_validate(d) for d in deposits],
)
@router.get("/games", response_model=list[GameInfo])
def list_games() -> list[GameInfo]:
return [slot_to_info(slot) for slot in list_slots()]
@router.get("/games/{slug}", response_model=GameInfo)
def get_game(slug: str) -> GameInfo:
slot = get_slot(slug)
if slot is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Game not found")
return slot_to_info(slot)
@router.post("/games/{slug}/spin", response_model=SessionSpinResponse)
def spin_game(
slug: str,
payload: SpinRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SessionSpinResponse:
if user.agreement_accepted_at is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accept user agreement first",
)
slot = get_slot(slug)
if slot is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Game not found")
if slot.status != "available":
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Game is not available yet",
)
bet = payload.bet.quantize(Decimal("0.01"))
spin_count = payload.spin_count
try:
slot.validate_bet(bet)
slot.validate_spin_count(spin_count)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
total_bet = (bet * spin_count).quantize(Decimal("0.01"))
balance = user.balance or Decimal("0.00")
if balance < total_bet:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Insufficient balance")
user.balance = balance - total_bet
try:
session_result = slot.play_session(bet, spin_count)
except Exception:
db.rollback()
raise
user.balance = (user.balance or Decimal("0.00")) + session_result.total_win
record = SpinSession(
user_id=user.id,
slug=slug,
bet=bet,
spin_count=spin_count,
total_win=session_result.total_win,
payload_json=json.dumps(session_result.to_payload(), ensure_ascii=False),
)
db.add(record)
db.commit()
db.refresh(user)
return SessionSpinResponse(
slug=slug,
bet=session_result.bet,
spin_count=session_result.spin_count,
total_bet=session_result.total_bet,
total_win=session_result.total_win,
max_level=session_result.max_level,
trigger_count=session_result.trigger_count,
balance=user.balance,
spins=[
SpinOutcomeResponse(
index=s.index,
grid=s.grid,
line_wins=[
PaylineWinResponse(
line_index=w.line_index,
symbol=w.symbol,
count=w.count,
amount=w.amount,
path=w.path,
)
for w in s.line_wins
],
scatter_count=s.scatter_count,
scatter_win=s.scatter_win,
total_win=s.total_win,
level=s.level,
multiplier=s.multiplier,
meta_triggered=s.meta_triggered,
)
for s in session_result.spins
],
)