9a2aea038a
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
424 lines
13 KiB
Python
424 lines
13 KiB
Python
"""Contests routes for cabinet - user participation in games/contests."""
|
|
|
|
import logging
|
|
import random
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.crud.contest import (
|
|
create_attempt,
|
|
get_active_rounds,
|
|
get_attempt,
|
|
increment_winner_count,
|
|
)
|
|
from app.database.crud.subscription import get_subscription_by_user_id
|
|
from app.database.models import SubscriptionStatus, User
|
|
from app.services.contest_rotation_service import (
|
|
GAME_ANAGRAM,
|
|
GAME_BLITZ,
|
|
GAME_CIPHER,
|
|
GAME_EMOJI,
|
|
GAME_LOCKS,
|
|
GAME_QUEST,
|
|
GAME_SERVER,
|
|
)
|
|
|
|
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
|
|
|
|
|
|
# ============ Schemas ============
|
|
|
|
|
|
class ContestInfo(BaseModel):
|
|
"""Contest/game info."""
|
|
|
|
id: int
|
|
slug: str
|
|
name: str
|
|
description: str | None = None
|
|
prize_type: str
|
|
prize_value: str
|
|
is_available: bool
|
|
already_played: bool = False
|
|
|
|
|
|
class ContestGameData(BaseModel):
|
|
"""Data for playing a contest game."""
|
|
|
|
round_id: int
|
|
game_type: str
|
|
game_data: dict[str, Any]
|
|
instructions: str
|
|
|
|
|
|
class ContestAnswerRequest(BaseModel):
|
|
"""Request to submit contest answer."""
|
|
|
|
round_id: int
|
|
answer: str
|
|
|
|
|
|
class ContestResult(BaseModel):
|
|
"""Result of contest attempt."""
|
|
|
|
is_winner: bool
|
|
message: str
|
|
prize_type: str | None = None
|
|
prize_value: str | None = None
|
|
|
|
|
|
# ============ Helpers ============
|
|
|
|
|
|
def _user_allowed(subscription) -> bool:
|
|
"""Check if user is allowed to participate in contests."""
|
|
if not subscription:
|
|
return False
|
|
return subscription.status in {
|
|
SubscriptionStatus.ACTIVE.value,
|
|
SubscriptionStatus.TRIAL.value,
|
|
}
|
|
|
|
|
|
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
|
|
"""Award prize to winner."""
|
|
if prize_type == 'days':
|
|
try:
|
|
days = int(prize_value)
|
|
except ValueError:
|
|
return 'Error: invalid prize value'
|
|
|
|
subscription = await get_subscription_by_user_id(db, user_id)
|
|
if not subscription:
|
|
return 'Error: subscription not found'
|
|
|
|
subscription.end_date = subscription.end_date + timedelta(days=days)
|
|
subscription.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
await db.refresh(subscription)
|
|
|
|
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
|
|
return f'Subscription extended by {days} days'
|
|
|
|
if prize_type == 'balance':
|
|
from app.database.crud.user import get_user_by_id
|
|
|
|
try:
|
|
amount = float(prize_value)
|
|
except ValueError:
|
|
return 'Error: invalid prize value'
|
|
|
|
user = await get_user_by_id(db, user_id)
|
|
if not user:
|
|
return 'Error: user not found'
|
|
|
|
user.balance += amount
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
|
|
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
|
|
return f'Balance increased by {amount}'
|
|
|
|
logger.warning(f'Unknown prize type: {prize_type}')
|
|
return f"Prize type '{prize_type}' not supported"
|
|
|
|
|
|
# ============ Routes ============
|
|
|
|
|
|
class ContestsCountResponse(BaseModel):
|
|
"""Count of available contests."""
|
|
|
|
count: int
|
|
|
|
|
|
@router.get('/count', response_model=ContestsCountResponse)
|
|
async def get_contests_count(
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Get count of contests available for the user."""
|
|
subscription = await get_subscription_by_user_id(db, user.id)
|
|
|
|
if not _user_allowed(subscription):
|
|
return ContestsCountResponse(count=0)
|
|
|
|
active_rounds = await get_active_rounds(db)
|
|
|
|
# Count unique available contests (not yet played)
|
|
count = 0
|
|
seen_templates = set()
|
|
for rnd in active_rounds:
|
|
if not rnd.template or not rnd.template.is_enabled:
|
|
continue
|
|
tpl_slug = rnd.template.slug if rnd.template else ''
|
|
if tpl_slug in seen_templates:
|
|
continue
|
|
seen_templates.add(tpl_slug)
|
|
|
|
# Check if user already played this round
|
|
attempt = await get_attempt(db, rnd.id, user.id)
|
|
if not attempt:
|
|
count += 1
|
|
|
|
return ContestsCountResponse(count=count)
|
|
|
|
|
|
@router.get('', response_model=list[ContestInfo])
|
|
async def get_contests(
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Get list of available contests/games."""
|
|
subscription = await get_subscription_by_user_id(db, user.id)
|
|
|
|
if not _user_allowed(subscription):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='Contests are only available for users with active or trial subscriptions',
|
|
)
|
|
|
|
active_rounds = await get_active_rounds(db)
|
|
|
|
# Group by template to avoid duplicates
|
|
unique_templates = {}
|
|
for rnd in active_rounds:
|
|
if not rnd.template or not rnd.template.is_enabled:
|
|
continue
|
|
tpl_slug = rnd.template.slug if rnd.template else ''
|
|
if tpl_slug not in unique_templates:
|
|
unique_templates[tpl_slug] = rnd
|
|
|
|
contests = []
|
|
for tpl_slug, rnd in unique_templates.items():
|
|
# Check if user already played this round
|
|
attempt = await get_attempt(db, rnd.id, user.id)
|
|
|
|
contests.append(
|
|
ContestInfo(
|
|
id=rnd.id,
|
|
slug=tpl_slug,
|
|
name=rnd.template.name if rnd.template else tpl_slug,
|
|
description=rnd.template.description if rnd.template else None,
|
|
prize_type=rnd.template.prize_type if rnd.template else 'days',
|
|
prize_value=rnd.template.prize_value if rnd.template else '1',
|
|
is_available=True,
|
|
already_played=attempt is not None,
|
|
)
|
|
)
|
|
|
|
return contests
|
|
|
|
|
|
@router.get('/{round_id}', response_model=ContestGameData)
|
|
async def get_contest_game(
|
|
round_id: int,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Get game data for a specific contest round."""
|
|
subscription = await get_subscription_by_user_id(db, user.id)
|
|
|
|
if not _user_allowed(subscription):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='Contests are only available for users with active or trial subscriptions',
|
|
)
|
|
|
|
active_rounds = await get_active_rounds(db)
|
|
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
|
|
|
if not round_obj:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Contest round not found or already finished',
|
|
)
|
|
|
|
if not round_obj.template or not round_obj.template.is_enabled:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='This contest is disabled',
|
|
)
|
|
|
|
# Check if already played
|
|
attempt = await get_attempt(db, round_id, user.id)
|
|
if attempt:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='You have already played this round',
|
|
)
|
|
|
|
tpl = round_obj.template
|
|
game_type = tpl.slug
|
|
game_data = {}
|
|
instructions = ''
|
|
|
|
if game_type == GAME_QUEST:
|
|
rows = round_obj.payload.get('rows', 3)
|
|
cols = round_obj.payload.get('cols', 3)
|
|
secret = random.randint(0, rows * cols - 1)
|
|
game_data = {
|
|
'rows': rows,
|
|
'cols': cols,
|
|
'secret': secret,
|
|
'grid_size': rows * cols,
|
|
}
|
|
instructions = 'Select one of the nodes in the grid. Find the hidden server!'
|
|
|
|
elif game_type == GAME_LOCKS:
|
|
total = round_obj.payload.get('total', 20)
|
|
secret = random.randint(0, total - 1)
|
|
game_data = {
|
|
'total': total,
|
|
'secret': secret,
|
|
}
|
|
instructions = 'Find the unlocked button among the locks!'
|
|
|
|
elif game_type == GAME_SERVER:
|
|
flags = round_obj.payload.get('flags') or []
|
|
shuffled_flags = flags.copy()
|
|
random.shuffle(shuffled_flags)
|
|
game_data = {
|
|
'flags': shuffled_flags,
|
|
}
|
|
instructions = 'Choose a server by clicking on a flag!'
|
|
|
|
elif game_type == GAME_CIPHER:
|
|
question = round_obj.payload.get('question', '')
|
|
game_data = {
|
|
'question': question,
|
|
'input_type': 'text',
|
|
}
|
|
instructions = 'Decrypt the cipher and enter the answer!'
|
|
|
|
elif game_type == GAME_EMOJI:
|
|
question = round_obj.payload.get('question', '🤔')
|
|
emoji_list = question.split()
|
|
random.shuffle(emoji_list)
|
|
game_data = {
|
|
'question': ' '.join(emoji_list),
|
|
'input_type': 'text',
|
|
}
|
|
instructions = 'Guess the service by emojis!'
|
|
|
|
elif game_type == GAME_ANAGRAM:
|
|
letters = round_obj.payload.get('letters', '')
|
|
game_data = {
|
|
'letters': letters,
|
|
'input_type': 'text',
|
|
}
|
|
instructions = 'Make a word from the given letters!'
|
|
|
|
elif game_type == GAME_BLITZ:
|
|
game_data = {
|
|
'button_text': "I'm here!",
|
|
}
|
|
instructions = 'Click the button as fast as you can!'
|
|
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Unknown contest type',
|
|
)
|
|
|
|
return ContestGameData(
|
|
round_id=round_id,
|
|
game_type=game_type,
|
|
game_data=game_data,
|
|
instructions=instructions,
|
|
)
|
|
|
|
|
|
@router.post('/{round_id}/answer', response_model=ContestResult)
|
|
async def submit_contest_answer(
|
|
round_id: int,
|
|
request: ContestAnswerRequest,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Submit answer for a contest round."""
|
|
subscription = await get_subscription_by_user_id(db, user.id)
|
|
|
|
if not _user_allowed(subscription):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='Contests are only available for users with active or trial subscriptions',
|
|
)
|
|
|
|
active_rounds = await get_active_rounds(db)
|
|
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
|
|
|
if not round_obj:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Contest round not found or already finished',
|
|
)
|
|
|
|
# Check if already played
|
|
attempt = await get_attempt(db, round_id, user.id)
|
|
if attempt:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='You have already played this round',
|
|
)
|
|
|
|
tpl = round_obj.template
|
|
answer = request.answer
|
|
is_winner = False
|
|
|
|
# Determine if winner based on game type
|
|
if tpl.slug == GAME_SERVER:
|
|
flags = round_obj.payload.get('flags') or []
|
|
secret_idx = round_obj.payload.get('secret_idx')
|
|
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ''
|
|
is_winner = answer == correct_flag
|
|
|
|
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
|
|
try:
|
|
parts = answer.split('_')
|
|
if len(parts) >= 2:
|
|
idx = int(parts[0])
|
|
secret = int(parts[1])
|
|
is_winner = idx == secret
|
|
except (ValueError, IndexError):
|
|
is_winner = False
|
|
|
|
elif tpl.slug == GAME_BLITZ:
|
|
is_winner = answer.lower() == 'blitz'
|
|
|
|
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
|
|
correct = (round_obj.payload.get('answer') or '').upper()
|
|
is_winner = correct and answer.upper() == correct
|
|
|
|
# Record attempt
|
|
await create_attempt(db, round_id=round_obj.id, user_id=user.id, answer=str(answer), is_winner=is_winner)
|
|
|
|
if is_winner:
|
|
await increment_winner_count(db, round_obj)
|
|
prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value)
|
|
return ContestResult(
|
|
is_winner=True,
|
|
message=f'🎉 Congratulations! You won! {prize_text}',
|
|
prize_type=tpl.prize_type,
|
|
prize_value=tpl.prize_value,
|
|
)
|
|
lose_messages = {
|
|
GAME_QUEST: ['Empty node', 'Wrong server', 'Try another'],
|
|
GAME_LOCKS: ['Locked', 'No access', 'Try again'],
|
|
GAME_SERVER: ['Server overloaded', 'No response', 'Try tomorrow'],
|
|
}
|
|
messages = lose_messages.get(tpl.slug, ['Incorrect', 'Try again next round'])
|
|
return ContestResult(
|
|
is_winner=False,
|
|
message=random.choice(messages),
|
|
)
|