import random from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Form from sqlalchemy import select, func from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser from app.core.config import settings from app.models import ( Marathon, MarathonStatus, Game, Challenge, Participant, Assignment, AssignmentStatus, Activity, ActivityType, EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType, DisputeStatus, ) from app.schemas import ( SpinResult, AssignmentResponse, CompleteResult, DropResult, GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse, TrackTimeRequest, ) from app.schemas.game import PlaythroughInfo from app.services.points import PointsService from app.services.events import event_service from app.services.storage import storage_service from app.services.coins import coins_service from app.services.consumables import consumables_service from app.api.v1.games import get_available_games_for_participant router = APIRouter(tags=["wheel"]) points_service = PointsService() async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Participant: result = await db.execute( select(Participant).where( Participant.user_id == user_id, Participant.marathon_id == marathon_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") return participant async def get_active_assignment(db, participant_id: int, is_event: bool = False) -> Assignment | None: """Get active assignment for participant. Args: db: Database session participant_id: Participant ID is_event: If True, get event assignment (Common Enemy). If False, get regular assignment. """ result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses ) .where( Assignment.participant_id == participant_id, Assignment.status == AssignmentStatus.ACTIVE.value, Assignment.is_event_assignment == is_event, ) ) return result.scalar_one_or_none() async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None: """Get the oldest returned assignment that needs to be redone.""" result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses ) .where( Assignment.participant_id == participant_id, Assignment.status == AssignmentStatus.RETURNED.value, Assignment.is_event_assignment == False, ) .order_by(Assignment.completed_at.asc()) # Oldest first .limit(1) ) return result.scalar_one_or_none() async def activate_returned_assignment(db, returned_assignment: Assignment) -> None: """ Re-activate a returned assignment. Simply changes the status back to ACTIVE. """ returned_assignment.status = AssignmentStatus.ACTIVE.value returned_assignment.started_at = datetime.utcnow() # Clear previous proof data for fresh attempt returned_assignment.proof_path = None returned_assignment.proof_url = None returned_assignment.proof_comment = None returned_assignment.completed_at = None returned_assignment.points_earned = 0 @router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): """Spin the wheel to get a random game and challenge (or playthrough)""" # Check marathon is active result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Marathon not found") if marathon.status != MarathonStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Marathon is not active") # Check if marathon has expired by end_date if marathon.end_date and datetime.utcnow() > marathon.end_date: raise HTTPException(status_code=400, detail="Marathon has ended") participant = await get_participant_or_403(db, current_user.id, marathon_id) # Check no active regular assignment (event assignments are separate) active = await get_active_assignment(db, participant.id, is_event=False) if active: raise HTTPException(status_code=400, detail="You already have an active assignment") # Get available games (filtered by completion status) available_games, _ = await get_available_games_for_participant(db, participant, marathon_id) if not available_games: raise HTTPException(status_code=400, detail="No games available for spin") # Check active event active_event = await event_service.get_active_event(db, marathon_id) game = None challenge = None is_playthrough = False # Handle special event cases (excluding Common Enemy - it has separate flow) # Events only apply to challenges-type games, not playthrough if active_event: if active_event.type == EventType.JACKPOT.value: # Jackpot: Get hard challenge only (from challenges-type games) challenge = await event_service.get_random_hard_challenge(db, marathon_id) if challenge: # Check if this game is available for the participant result = await db.execute( select(Game).where(Game.id == challenge.game_id) ) game = result.scalar_one_or_none() if game and game.id in [g.id for g in available_games]: # Consume jackpot (one-time use) await event_service.consume_jackpot(db, active_event.id) else: # Game not available, fall back to normal selection game = None challenge = None # Note: Common Enemy is handled separately via event-assignment endpoints # Normal random selection if no special event handling if not game: game = random.choice(available_games) if game.game_type == GameType.PLAYTHROUGH.value: # Playthrough game - no challenge selection # Events that apply to playthrough: GOLDEN_HOUR, DOUBLE_RISK, COMMON_ENEMY # Events that DON'T apply: JACKPOT (hard challenges only) is_playthrough = True challenge = None if active_event and active_event.type == EventType.JACKPOT.value: active_event = None # Jackpot doesn't apply to playthrough else: # Challenges game - select random challenge if not game.challenges: # Reload challenges if not loaded result = await db.execute( select(Game) .options(selectinload(Game.challenges)) .where(Game.id == game.id) ) game = result.scalar_one() # Filter out already completed challenges completed_result = await db.execute( select(Assignment.challenge_id) .where( Assignment.participant_id == participant.id, Assignment.challenge_id.in_([c.id for c in game.challenges]), Assignment.status == AssignmentStatus.COMPLETED.value, ) ) completed_ids = set(completed_result.scalars().all()) available_challenges = [c for c in game.challenges if c.id not in completed_ids] if not available_challenges: raise HTTPException(status_code=400, detail="No challenges available for this game") challenge = random.choice(available_challenges) # Create assignment if is_playthrough: # Playthrough assignment - link to game, not challenge assignment = Assignment( participant_id=participant.id, game_id=game.id, is_playthrough=True, status=AssignmentStatus.ACTIVE.value, event_type=active_event.type if active_event else None, ) db.add(assignment) await db.flush() # Get assignment.id for bonus assignments # Create bonus assignments for all challenges bonus_challenges = [] if game.challenges: for ch in game.challenges: bonus = BonusAssignment( main_assignment_id=assignment.id, challenge_id=ch.id, ) db.add(bonus) bonus_challenges.append(ch) # Log activity activity_data = { "game": game.title, "is_playthrough": True, "points": game.playthrough_points, "bonus_challenges_count": len(bonus_challenges), } if active_event: activity_data["event_type"] = active_event.type else: # Regular challenge assignment assignment = Assignment( participant_id=participant.id, challenge_id=challenge.id, status=AssignmentStatus.ACTIVE.value, event_type=active_event.type if active_event else None, ) db.add(assignment) # Log activity activity_data = { "game": game.title, "challenge": challenge.title, "difficulty": challenge.difficulty, "points": challenge.points, } if active_event: activity_data["event_type"] = active_event.type activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.SPIN.value, data=activity_data, ) db.add(activity) await db.commit() await db.refresh(assignment) # Calculate drop penalty if is_playthrough: drop_penalty = points_service.calculate_drop_penalty( participant.drop_count, game.playthrough_points, None # No events for playthrough ) else: drop_penalty = points_service.calculate_drop_penalty( participant.drop_count, challenge.points, active_event ) # Get challenges count challenges_count = 0 if 'challenges' in game.__dict__: challenges_count = len(game.challenges) else: challenges_count = await db.scalar( select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) ) # Build response game_response = GameResponse( id=game.id, title=game.title, cover_url=storage_service.get_url(game.cover_path, "covers"), download_url=game.download_url, genre=game.genre, added_by=None, challenges_count=challenges_count, created_at=game.created_at, game_type=game.game_type, playthrough_points=game.playthrough_points, playthrough_description=game.playthrough_description, playthrough_proof_type=game.playthrough_proof_type, playthrough_proof_hint=game.playthrough_proof_hint, ) if is_playthrough: # Return playthrough result return SpinResult( assignment_id=assignment.id, game=game_response, challenge=None, is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, points=game.playthrough_points, proof_type=game.playthrough_proof_type, proof_hint=game.playthrough_proof_hint, ), bonus_challenges=[ ChallengeResponse( id=ch.id, title=ch.title, description=ch.description, type=ch.type, difficulty=ch.difficulty, points=ch.points, estimated_time=ch.estimated_time, proof_type=ch.proof_type, proof_hint=ch.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ch.is_generated, created_at=ch.created_at, ) for ch in bonus_challenges ], can_drop=True, drop_penalty=drop_penalty, event_type=active_event.type if active_event else None, ) else: # Return challenge result return SpinResult( assignment_id=assignment.id, game=game_response, challenge=ChallengeResponse( id=challenge.id, title=challenge.title, description=challenge.description, type=challenge.type, difficulty=challenge.difficulty, points=challenge.points, estimated_time=challenge.estimated_time, proof_type=challenge.proof_type, proof_hint=challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=challenge.is_generated, created_at=challenge.created_at, ), is_playthrough=False, can_drop=True, drop_penalty=drop_penalty, event_type=active_event.type if active_event else None, ) @router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None) async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession): """Get current active regular assignment (not event assignments)""" participant = await get_participant_or_403(db, current_user.id, marathon_id) assignment = await get_active_assignment(db, participant.id, is_event=False) # If no active assignment, check for returned assignments if not assignment: returned = await get_oldest_returned_assignment(db, participant.id) if returned: # Activate the returned assignment await activate_returned_assignment(db, returned) await db.commit() # Reload with all relationships assignment = await get_active_assignment(db, participant.id, is_event=False) if not assignment: return None # Handle playthrough assignments if assignment.is_playthrough: game = assignment.game # Use stored event_type for playthrough # All events except JACKPOT apply (DOUBLE_RISK = free drop, others affect points) playthrough_event = None if assignment.event_type and assignment.event_type != EventType.JACKPOT.value: class MockEvent: def __init__(self, event_type): self.type = event_type playthrough_event = MockEvent(assignment.event_type) drop_penalty = points_service.calculate_drop_penalty( participant.drop_count, game.playthrough_points, playthrough_event ) # Build bonus challenges response from app.schemas.assignment import BonusAssignmentResponse bonus_responses = [] for ba in assignment.bonus_assignments: bonus_responses.append(BonusAssignmentResponse( id=ba.id, challenge=ChallengeResponse( id=ba.challenge.id, title=ba.challenge.title, description=ba.challenge.description, type=ba.challenge.type, difficulty=ba.challenge.difficulty, points=ba.challenge.points, estimated_time=ba.challenge.estimated_time, proof_type=ba.challenge.proof_type, proof_hint=ba.challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ba.challenge.is_generated, created_at=ba.challenge.created_at, ), status=ba.status, proof_url=ba.proof_url, proof_comment=ba.proof_comment, points_earned=ba.points_earned, completed_at=ba.completed_at, )) return AssignmentResponse( id=assignment.id, challenge=None, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, points=game.playthrough_points, proof_type=game.playthrough_proof_type, proof_hint=game.playthrough_proof_hint, ), status=assignment.status, proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url, proof_comment=assignment.proof_comment, points_earned=assignment.points_earned, streak_at_completion=assignment.streak_at_completion, started_at=assignment.started_at, completed_at=assignment.completed_at, drop_penalty=drop_penalty, bonus_challenges=bonus_responses, event_type=assignment.event_type, tracked_time_minutes=assignment.tracked_time_minutes, ) # Regular challenge assignment challenge = assignment.challenge game = challenge.game # Calculate drop penalty (considers active event for double_risk) active_event = await event_service.get_active_event(db, marathon_id) drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event) return AssignmentResponse( id=assignment.id, challenge=ChallengeResponse( id=challenge.id, title=challenge.title, description=challenge.description, type=challenge.type, difficulty=challenge.difficulty, points=challenge.points, estimated_time=challenge.estimated_time, proof_type=challenge.proof_type, proof_hint=challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=challenge.is_generated, created_at=challenge.created_at, ), status=assignment.status, proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url, proof_comment=assignment.proof_comment, points_earned=assignment.points_earned, streak_at_completion=assignment.streak_at_completion, started_at=assignment.started_at, completed_at=assignment.completed_at, drop_penalty=drop_penalty, event_type=assignment.event_type, tracked_time_minutes=assignment.tracked_time_minutes, ) @router.post("/assignments/{assignment_id}/complete", response_model=CompleteResult) async def complete_assignment( assignment_id: int, current_user: CurrentUser, db: DbSession, proof_url: str | None = Form(None), comment: str | None = Form(None), proof_file: UploadFile | None = File(None), # Legacy single file support proof_files: list[UploadFile] = File([]), # Multiple files support ): """Complete a regular assignment with proof (not event assignments)""" # Get assignment with all needed relationships result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points selectinload(Assignment.dispute), # To check if it was previously disputed ) .where(Assignment.id == assignment_id) ) assignment = result.scalar_one_or_none() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") if assignment.participant.user_id != current_user.id: raise HTTPException(status_code=403, detail="This is not your assignment") if assignment.status != AssignmentStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Assignment is not active") # Event assignments should be completed via /event-assignments/{id}/complete if assignment.is_event_assignment: raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments") # Combine legacy single file with new multiple files all_files = [] if proof_file: all_files.append(proof_file) if proof_files: all_files.extend(proof_files) # For playthrough: need either file(s) or URL or comment (proof is flexible) # For challenges: need either file(s) or URL if assignment.is_playthrough: if not all_files and not proof_url and not comment: raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)") else: if not all_files and not proof_url: raise HTTPException(status_code=400, detail="Proof is required (file or URL)") # Handle multiple file uploads if all_files: from app.models import AssignmentProof for idx, file in enumerate(all_files): contents = await file.read() if len(contents) > settings.MAX_UPLOAD_SIZE: raise HTTPException( status_code=400, detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", ) ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" if ext not in settings.ALLOWED_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}", ) # Determine file type (image or video) file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image" # Upload file to storage filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename) file_path = await storage_service.upload_file( content=contents, folder="proofs", filename=filename, content_type=file.content_type or "application/octet-stream", ) # Create AssignmentProof record proof_record = AssignmentProof( assignment_id=assignment_id, file_path=file_path, file_type=file_type, order_index=idx ) db.add(proof_record) # Legacy: set proof_path on first file for backward compatibility if idx == 0: assignment.proof_path = file_path # Set proof URL if provided if proof_url: assignment.proof_url = proof_url assignment.proof_comment = comment participant = assignment.participant # Handle playthrough completion if assignment.is_playthrough: game = assignment.game marathon_id = game.marathon_id # If tracked time exists (from desktop app), calculate points as hours * 30 # Otherwise use admin-set playthrough_points if assignment.tracked_time_minutes > 0: hours = assignment.tracked_time_minutes / 60 base_playthrough_points = int(hours * 30) else: base_playthrough_points = game.playthrough_points # Calculate BASE bonus points from completed bonus assignments (before multiplier) base_bonus_points = sum( ba.challenge.points for ba in assignment.bonus_assignments if ba.status == BonusAssignmentStatus.COMPLETED.value ) # Total base = playthrough + all bonuses total_base_points = base_playthrough_points + base_bonus_points # Get event for playthrough (use stored event_type from assignment) # All events except JACKPOT apply to playthrough playthrough_event = None if assignment.event_type and assignment.event_type != EventType.JACKPOT.value: class MockEvent: def __init__(self, event_type): self.type = event_type playthrough_event = MockEvent(assignment.event_type) # Apply multiplier to the TOTAL (base + bonuses), then add streak bonus total_points, streak_bonus, event_bonus = points_service.calculate_completion_points( total_base_points, participant.current_streak, playthrough_event ) # Update bonus assignments to reflect multiplied points for display if playthrough_event: multiplier = points_service.EVENT_MULTIPLIERS.get(playthrough_event.type, 1.0) for ba in assignment.bonus_assignments: if ba.status == BonusAssignmentStatus.COMPLETED.value: ba.points_earned = int(ba.challenge.points * multiplier) # Apply boost and lucky dice multipliers from consumables boost_multiplier = consumables_service.consume_boost_on_complete(participant) lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant) combined_multiplier = boost_multiplier * lucky_dice_multiplier if combined_multiplier != 1.0: total_points = int(total_points * combined_multiplier) # Update assignment assignment.status = AssignmentStatus.COMPLETED.value assignment.points_earned = total_points assignment.streak_at_completion = participant.current_streak + 1 assignment.completed_at = datetime.utcnow() # Update participant participant.total_points += total_points participant.current_streak += 1 participant.drop_count = 0 # Get marathon and award coins if certified marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) marathon = marathon_result.scalar_one() coins_earned = 0 if marathon.is_certified: coins_earned = await coins_service.award_playthrough_coins( db, current_user, participant, marathon, total_points, assignment.id ) # Check if this is a redo of a previously disputed assignment is_redo = ( assignment.dispute is not None and assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value ) # Log activity activity_data = { "assignment_id": assignment.id, "game": game.title, "is_playthrough": True, "points": total_points, "base_points": base_playthrough_points, "bonus_points": base_bonus_points, "streak": participant.current_streak, } if is_redo: activity_data["is_redo"] = True if boost_multiplier > 1.0: activity_data["boost_multiplier"] = boost_multiplier if lucky_dice_multiplier != 1.0: activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier if coins_earned > 0: activity_data["coins_earned"] = coins_earned if playthrough_event: activity_data["event_type"] = playthrough_event.type activity_data["event_bonus"] = event_bonus activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.COMPLETE.value, data=activity_data, ) db.add(activity) await db.commit() # Check for returned assignments returned_assignment = await get_oldest_returned_assignment(db, participant.id) if returned_assignment: await activate_returned_assignment(db, returned_assignment) await db.commit() return CompleteResult( points_earned=total_points, streak_bonus=streak_bonus, total_points=participant.total_points, new_streak=participant.current_streak, coins_earned=coins_earned, ) # Regular challenge completion challenge = assignment.challenge marathon_id = challenge.game.marathon_id # Check active event for point multipliers active_event = await event_service.get_active_event(db, marathon_id) # For jackpot: use the event_type stored in assignment (since event may be over) effective_event = active_event # Handle assignment-level event types (jackpot) if assignment.event_type == EventType.JACKPOT.value: class MockEvent: def __init__(self, event_type): self.type = event_type effective_event = MockEvent(assignment.event_type) total_points, streak_bonus, event_bonus = points_service.calculate_completion_points( challenge.points, participant.current_streak, effective_event ) # Handle common enemy bonus common_enemy_bonus = 0 common_enemy_closed = False common_enemy_winners = None if active_event and active_event.type == EventType.COMMON_ENEMY.value: common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion( db, active_event, participant.id, current_user.id ) total_points += common_enemy_bonus print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}") # Apply boost and lucky dice multipliers from consumables boost_multiplier = consumables_service.consume_boost_on_complete(participant) lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant) combined_multiplier = boost_multiplier * lucky_dice_multiplier if combined_multiplier != 1.0: total_points = int(total_points * combined_multiplier) # Update assignment assignment.status = AssignmentStatus.COMPLETED.value assignment.points_earned = total_points assignment.streak_at_completion = participant.current_streak + 1 assignment.completed_at = datetime.utcnow() # Update participant participant.total_points += total_points participant.current_streak += 1 participant.drop_count = 0 # Get marathon and award coins if certified marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) marathon = marathon_result.scalar_one() coins_earned = 0 if marathon.is_certified: coins_earned = await coins_service.award_challenge_coins( db, current_user, participant, marathon, challenge.difficulty, assignment.id ) # Check if this is a redo of a previously disputed assignment is_redo = ( assignment.dispute is not None and assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value ) # Log activity activity_data = { "assignment_id": assignment.id, "game": challenge.game.title, "challenge": challenge.title, "difficulty": challenge.difficulty, "points": total_points, "streak": participant.current_streak, } if is_redo: activity_data["is_redo"] = True if boost_multiplier > 1.0: activity_data["boost_multiplier"] = boost_multiplier if lucky_dice_multiplier != 1.0: activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier if coins_earned > 0: activity_data["coins_earned"] = coins_earned if assignment.event_type == EventType.JACKPOT.value: activity_data["event_type"] = assignment.event_type activity_data["event_bonus"] = event_bonus elif active_event: activity_data["event_type"] = active_event.type activity_data["event_bonus"] = event_bonus if common_enemy_bonus: activity_data["common_enemy_bonus"] = common_enemy_bonus activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.COMPLETE.value, data=activity_data, ) db.add(activity) # If common enemy event auto-closed, log the event end with winners if common_enemy_closed and common_enemy_winners: from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES winner_user_ids = [w["user_id"] for w in common_enemy_winners] users_result = await db.execute( select(User).where(User.id.in_(winner_user_ids)) ) users_map = {u.id: u.nickname for u in users_result.scalars().all()} winners_data = [ { "user_id": w["user_id"], "nickname": users_map.get(w["user_id"], "Unknown"), "rank": w["rank"], "bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0), } for w in common_enemy_winners ] print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}") event_end_activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.EVENT_END.value, data={ "event_type": EventType.COMMON_ENEMY.value, "event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"), "auto_closed": True, "winners": winners_data, }, ) db.add(event_end_activity) await db.commit() # Check for returned assignments returned_assignment = await get_oldest_returned_assignment(db, participant.id) if returned_assignment: await activate_returned_assignment(db, returned_assignment) await db.commit() print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}") return CompleteResult( points_earned=total_points, streak_bonus=streak_bonus, total_points=participant.total_points, new_streak=participant.current_streak, coins_earned=coins_earned, ) @router.patch("/assignments/{assignment_id}/track-time", response_model=MessageResponse) async def track_assignment_time( assignment_id: int, data: TrackTimeRequest, current_user: CurrentUser, db: DbSession, ): """Update tracked time for an assignment (from desktop app)""" result = await db.execute( select(Assignment) .options(selectinload(Assignment.participant)) .where(Assignment.id == assignment_id) ) assignment = result.scalar_one_or_none() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") if assignment.participant.user_id != current_user.id: raise HTTPException(status_code=403, detail="This is not your assignment") if assignment.status != AssignmentStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Assignment is not active") # Update tracked time (replace with new value) assignment.tracked_time_minutes = max(0, data.minutes) await db.commit() return MessageResponse(message=f"Tracked time updated to {data.minutes} minutes") @router.post("/assignments/{assignment_id}/drop", response_model=DropResult) async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): """Drop current assignment""" # Get assignment with all needed relationships result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop ) .where(Assignment.id == assignment_id) ) assignment = result.scalar_one_or_none() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") if assignment.participant.user_id != current_user.id: raise HTTPException(status_code=403, detail="This is not your assignment") if assignment.status != AssignmentStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Assignment is not active") participant = assignment.participant # Handle playthrough drop if assignment.is_playthrough: game = assignment.game marathon_id = game.marathon_id # Use stored event_type for drop penalty calculation # DOUBLE_RISK = free drop (0 penalty) playthrough_event = None if assignment.event_type and assignment.event_type != EventType.JACKPOT.value: class MockEvent: def __init__(self, event_type): self.type = event_type playthrough_event = MockEvent(assignment.event_type) penalty = points_service.calculate_drop_penalty( participant.drop_count, game.playthrough_points, playthrough_event ) # Save drop data for potential undo consumables_service.save_drop_for_undo( participant, penalty, participant.current_streak ) # Update assignment assignment.status = AssignmentStatus.DROPPED.value assignment.completed_at = datetime.utcnow() # Reset all bonus assignments (lose any completed bonuses) completed_bonuses_count = 0 for ba in assignment.bonus_assignments: if ba.status == BonusAssignmentStatus.COMPLETED.value: completed_bonuses_count += 1 ba.status = BonusAssignmentStatus.PENDING.value ba.proof_path = None ba.proof_url = None ba.proof_comment = None ba.points_earned = 0 ba.completed_at = None # Update participant participant.total_points = max(0, participant.total_points - penalty) participant.current_streak = 0 participant.drop_count += 1 # Log activity activity_data = { "game": game.title, "is_playthrough": True, "penalty": penalty, "lost_bonuses": completed_bonuses_count, } if playthrough_event: activity_data["event_type"] = playthrough_event.type activity_data["free_drop"] = True activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.DROP.value, data=activity_data, ) db.add(activity) await db.commit() return DropResult( penalty=penalty, total_points=participant.total_points, new_drop_count=participant.drop_count, ) # Regular challenge drop marathon_id = assignment.challenge.game.marathon_id # Check active event for free drops (double_risk) active_event = await event_service.get_active_event(db, marathon_id) # Calculate penalty (0 if double_risk event is active) penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event) # Save drop data for potential undo consumables_service.save_drop_for_undo( participant, penalty, participant.current_streak ) # Update assignment assignment.status = AssignmentStatus.DROPPED.value assignment.completed_at = datetime.utcnow() # Update participant participant.total_points = max(0, participant.total_points - penalty) participant.current_streak = 0 participant.drop_count += 1 # Log activity activity_data = { "game": assignment.challenge.game.title, "challenge": assignment.challenge.title, "difficulty": assignment.challenge.difficulty, "penalty": penalty, } if active_event: activity_data["event_type"] = active_event.type if active_event.type == EventType.DOUBLE_RISK.value: activity_data["free_drop"] = True activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.DROP.value, data=activity_data, ) db.add(activity) await db.commit() return DropResult( penalty=penalty, total_points=participant.total_points, new_drop_count=participant.drop_count, ) @router.get("/marathons/{marathon_id}/my-history", response_model=list[AssignmentResponse]) async def get_my_history( marathon_id: int, current_user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0, ): """Get history of user's assignments in marathon""" participant = await get_participant_or_403(db, current_user.id, marathon_id) result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses ) .where(Assignment.participant_id == participant.id) .order_by(Assignment.started_at.desc()) .limit(limit) .offset(offset) ) assignments = result.scalars().all() responses = [] for a in assignments: if a.is_playthrough: # Playthrough assignment game = a.game from app.schemas.assignment import BonusAssignmentResponse bonus_responses = [ BonusAssignmentResponse( id=ba.id, challenge=ChallengeResponse( id=ba.challenge.id, title=ba.challenge.title, description=ba.challenge.description, type=ba.challenge.type, difficulty=ba.challenge.difficulty, points=ba.challenge.points, estimated_time=ba.challenge.estimated_time, proof_type=ba.challenge.proof_type, proof_hint=ba.challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ba.challenge.is_generated, created_at=ba.challenge.created_at, ), status=ba.status, proof_url=ba.proof_url, proof_comment=ba.proof_comment, points_earned=ba.points_earned, completed_at=ba.completed_at, ) for ba in a.bonus_assignments ] responses.append(AssignmentResponse( id=a.id, challenge=None, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, points=game.playthrough_points, proof_type=game.playthrough_proof_type, proof_hint=game.playthrough_proof_hint, ), status=a.status, proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url, proof_comment=a.proof_comment, points_earned=a.points_earned, streak_at_completion=a.streak_at_completion, started_at=a.started_at, completed_at=a.completed_at, bonus_challenges=bonus_responses, tracked_time_minutes=a.tracked_time_minutes, )) else: # Regular challenge assignment responses.append(AssignmentResponse( id=a.id, challenge=ChallengeResponse( id=a.challenge.id, title=a.challenge.title, description=a.challenge.description, type=a.challenge.type, difficulty=a.challenge.difficulty, points=a.challenge.points, estimated_time=a.challenge.estimated_time, proof_type=a.challenge.proof_type, proof_hint=a.challenge.proof_hint, game=GameShort( id=a.challenge.game.id, title=a.challenge.game.title, cover_url=None, download_url=a.challenge.game.download_url, game_type=a.challenge.game.game_type, ), is_generated=a.challenge.is_generated, created_at=a.challenge.created_at, ), status=a.status, proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url, proof_comment=a.proof_comment, points_earned=a.points_earned, streak_at_completion=a.streak_at_completion, started_at=a.started_at, completed_at=a.completed_at, tracked_time_minutes=a.tracked_time_minutes, )) return responses