From 18fe95effc5a71c1d290e95284df360c8e8a4be8 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Mon, 5 Jan 2026 23:41:22 +0700 Subject: [PATCH 1/2] Fix events --- backend/app/api/v1/assignments.py | 20 +++++- backend/app/api/v1/wheel.py | 101 ++++++++++++++++++++++-------- backend/app/schemas/assignment.py | 2 + frontend/src/types/index.ts | 2 + frontend/src/utils/activity.ts | 3 +- 5 files changed, 99 insertions(+), 29 deletions(-) diff --git a/backend/app/api/v1/assignments.py b/backend/app/api/v1/assignments.py index 470b14f..81d1da4 100644 --- a/backend/app/api/v1/assignments.py +++ b/backend/app/api/v1/assignments.py @@ -1335,17 +1335,31 @@ async def complete_bonus_assignment( bonus_assignment.proof_url = proof_url # Complete the bonus assignment + # NOTE: We store BASE points here. Event multiplier is applied when main assignment is completed + # This ensures multiplier is applied to the SUM (base + all bonuses), not separately + bonus_assignment.points_earned = bonus_assignment.challenge.points bonus_assignment.status = BonusAssignmentStatus.COMPLETED.value bonus_assignment.proof_comment = comment - bonus_assignment.points_earned = bonus_assignment.challenge.points bonus_assignment.completed_at = datetime.utcnow() # If main assignment is already COMPLETED, add bonus points immediately # This handles the case where a bonus was disputed and user is re-completing it if assignment.status == AssignmentStatus.COMPLETED.value: + from app.models import EventType + from app.services.points import PointsService + + # Apply event multiplier if assignment had one + points_to_add = bonus_assignment.points_earned + if assignment.event_type: + ps = PointsService() + multiplier = ps.EVENT_MULTIPLIERS.get(assignment.event_type, 1.0) + points_to_add = int(bonus_assignment.points_earned * multiplier) + # Update bonus assignment to show multiplied points + bonus_assignment.points_earned = points_to_add + participant = assignment.participant - participant.total_points += bonus_assignment.points_earned - assignment.points_earned += bonus_assignment.points_earned + participant.total_points += points_to_add + assignment.points_earned += points_to_add # NOTE: If main is not completed yet, points will be added when main is completed # This prevents exploiting by dropping the main assignment after getting bonus points diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index b3a636c..aa86799 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -161,10 +161,13 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) game = random.choice(available_games) if game.game_type == GameType.PLAYTHROUGH.value: - # Playthrough game - no challenge selection, ignore events + # 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 - active_event = None # Ignore events for playthrough + 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: @@ -201,7 +204,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) game_id=game.id, is_playthrough=True, status=AssignmentStatus.ACTIVE.value, - # No event_type for playthrough + event_type=active_event.type if active_event else None, ) db.add(assignment) await db.flush() # Get assignment.id for bonus assignments @@ -224,6 +227,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) "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( @@ -323,6 +328,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) ], can_drop=True, drop_penalty=drop_penalty, + event_type=active_event.type if active_event else None, ) else: # Return challenge result @@ -346,6 +352,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) is_playthrough=False, can_drop=True, drop_penalty=drop_penalty, + event_type=active_event.type if active_event else None, ) @@ -371,9 +378,17 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db # Handle playthrough assignments if assignment.is_playthrough: game = assignment.game - active_event = None # No events for playthrough + # 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, None + participant.drop_count, game.playthrough_points, playthrough_event ) # Build bonus challenges response @@ -423,6 +438,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db completed_at=assignment.completed_at, drop_penalty=drop_penalty, bonus_challenges=bonus_responses, + event_type=assignment.event_type, ) # Regular challenge assignment @@ -457,6 +473,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db started_at=assignment.started_at, completed_at=assignment.completed_at, drop_penalty=drop_penalty, + event_type=assignment.event_type, ) @@ -570,19 +587,37 @@ async def complete_assignment( if assignment.is_playthrough: game = assignment.game marathon_id = game.marathon_id - base_points = game.playthrough_points + base_playthrough_points = game.playthrough_points - # No events for playthrough - total_points, streak_bonus, _ = points_service.calculate_completion_points( - base_points, participant.current_streak, None - ) - - # Calculate bonus points from completed bonus assignments - bonus_points = sum( - ba.points_earned for ba in assignment.bonus_assignments + # 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_points += bonus_points + + # 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) # Update assignment assignment.status = AssignmentStatus.COMPLETED.value @@ -607,12 +642,15 @@ async def complete_assignment( "game": game.title, "is_playthrough": True, "points": total_points, - "base_points": base_points, - "bonus_points": bonus_points, + "base_points": base_playthrough_points, + "bonus_points": base_bonus_points, "streak": participant.current_streak, } if is_redo: activity_data["is_redo"] = True + if playthrough_event: + activity_data["event_type"] = playthrough_event.type + activity_data["event_bonus"] = event_bonus activity = Activity( marathon_id=marathon_id, @@ -796,9 +834,17 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS game = assignment.game marathon_id = game.marathon_id - # No events for playthrough + # 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, None + participant.drop_count, game.playthrough_points, playthrough_event ) # Update assignment @@ -823,16 +869,21 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS 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={ - "game": game.title, - "is_playthrough": True, - "penalty": penalty, - "lost_bonuses": completed_bonuses_count, - }, + data=activity_data, ) db.add(activity) diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index 67d892f..f109d44 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -56,6 +56,7 @@ class AssignmentResponse(BaseModel): completed_at: datetime | None = None drop_penalty: int = 0 # Calculated penalty if dropped bonus_challenges: list[BonusAssignmentResponse] = [] # Для playthrough + event_type: str | None = None # Event type if assignment was created during event class Config: from_attributes = True @@ -70,6 +71,7 @@ class SpinResult(BaseModel): bonus_challenges: list[ChallengeResponse] = [] # Для playthrough - список доступных бонусных челленджей can_drop: bool drop_penalty: int + event_type: str | None = None # Event type if active during spin class CompleteResult(BaseModel): diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 396df72..e18b85c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -268,6 +268,7 @@ export interface Assignment { completed_at: string | null drop_penalty: number bonus_challenges?: BonusAssignment[] // For playthrough + event_type?: EventType // Event active when assignment was created } export interface SpinResult { @@ -279,6 +280,7 @@ export interface SpinResult { bonus_challenges?: Challenge[] // Available bonus challenges for playthrough can_drop: boolean drop_penalty: number + event_type?: EventType // Event active during spin } export interface CompleteResult { diff --git a/frontend/src/utils/activity.ts b/frontend/src/utils/activity.ts index ede41e0..1941aa6 100644 --- a/frontend/src/utils/activity.ts +++ b/frontend/src/utils/activity.ts @@ -152,11 +152,12 @@ export function formatActivityMessage(activity: Activity): { title: string; deta const points = data.points ? `+${data.points}` : '' const streak = data.streak && (data.streak as number) > 1 ? `серия ${data.streak}` : '' const bonus = data.common_enemy_bonus ? `+${data.common_enemy_bonus} бонус` : '' + const eventBonus = data.event_bonus ? `(+${data.event_bonus} ${EVENT_NAMES[data.event_type as EventType] || 'бонус'})` : '' return { title: `завершил ${points}`, details: challenge || undefined, - extra: [game, streak, bonus].filter(Boolean).join(' • ') || undefined, + extra: [game, streak, bonus, eventBonus].filter(Boolean).join(' • ') || undefined, } } From ca49e42f749e2a1cade6daa48af879cee746b914 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Thu, 8 Jan 2026 05:29:55 +0700 Subject: [PATCH 2/2] Fix common enemy --- backend/app/api/v1/events.py | 125 +++++++++++++++++++++++++++++---- backend/app/services/events.py | 50 +++++++++++-- 2 files changed, 154 insertions(+), 21 deletions(-) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 724f33b..48e9401 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser from app.models import ( Marathon, MarathonStatus, Participant, ParticipantRole, - Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, + Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game, SwapRequest as SwapRequestModel, SwapRequestStatus, User, ) from fastapi import UploadFile, File, Form @@ -150,6 +150,46 @@ async def start_event( detail="Common enemy event requires challenge_id" ) + # Handle playthrough games (negative challenge_id = -game_id) + challenge_id = data.challenge_id + game_id = None + is_playthrough = False + + if data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id < 0: + # This is a playthrough game, not a real challenge + game_id = -challenge_id # Convert negative to positive game_id + challenge_id = None + is_playthrough = True + + # Verify game exists and is a playthrough game + from app.models.game import GameType + result = await db.execute( + select(Game).where( + Game.id == game_id, + Game.marathon_id == marathon_id, + Game.game_type == GameType.PLAYTHROUGH.value, + ) + ) + game = result.scalar_one_or_none() + if not game: + raise HTTPException( + status_code=400, + detail="Playthrough game not found" + ) + elif data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id > 0: + # Verify regular challenge exists + result = await db.execute( + select(Challenge) + .options(selectinload(Challenge.game)) + .where(Challenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if not challenge or challenge.game.marathon_id != marathon_id: + raise HTTPException( + status_code=400, + detail="Challenge not found in this marathon" + ) + try: event = await event_service.start_event( db=db, @@ -157,7 +197,9 @@ async def start_event( event_type=data.type, created_by_id=current_user.id, duration_minutes=data.duration_minutes, - challenge_id=data.challenge_id, + challenge_id=challenge_id, + game_id=game_id, + is_playthrough=is_playthrough, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -919,6 +961,41 @@ async def get_common_enemy_leaderboard( def assignment_to_response(assignment: Assignment) -> AssignmentResponse: """Convert Assignment model to AssignmentResponse""" + # Handle playthrough assignments (no challenge, only game) + if assignment.is_playthrough and assignment.game: + game = assignment.game + return AssignmentResponse( + id=assignment.id, + challenge=ChallengeResponse( + id=-game.id, # Negative ID for playthrough + title=f"Прохождение: {game.title}", + description=game.playthrough_description or "Пройдите игру", + type="completion", + difficulty="medium", + points=game.playthrough_points or 0, + estimated_time=None, + proof_type=game.playthrough_proof_type or "screenshot", + proof_hint=game.playthrough_proof_hint, + game=GameShort( + id=game.id, + title=game.title, + cover_url=storage_service.get_url(game.cover_path, "covers"), + download_url=game.download_url, + game_type=game.game_type, + ), + is_generated=False, + created_at=game.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, + ) + + # Regular challenge assignment challenge = assignment.challenge game = challenge.game return AssignmentResponse( @@ -969,7 +1046,8 @@ async def get_event_assignment( result = await db.execute( select(Assignment) .options( - selectinload(Assignment.challenge).selectinload(Challenge.game) + selectinload(Assignment.challenge).selectinload(Challenge.game), + selectinload(Assignment.game), # For playthrough assignments ) .where( Assignment.participant_id == participant.id, @@ -1000,10 +1078,19 @@ async def get_event_assignment( is_completed=False, ) + # Determine challenge_id for response (negative for playthrough) + challenge_id_response = None + if event and event.data: + if event.data.get("is_playthrough"): + game_id = event.data.get("game_id") + challenge_id_response = -game_id if game_id else None + else: + challenge_id_response = event.data.get("challenge_id") + return EventAssignmentResponse( assignment=assignment_to_response(assignment) if assignment else None, event_id=event.id if event else None, - challenge_id=event.data.get("challenge_id") if event and event.data else None, + challenge_id=challenge_id_response, is_completed=is_completed, ) @@ -1027,6 +1114,7 @@ async def complete_event_assignment( .options( selectinload(Assignment.participant), selectinload(Assignment.challenge).selectinload(Challenge.game), + selectinload(Assignment.game), # For playthrough assignments ) .where(Assignment.id == assignment_id) ) @@ -1080,17 +1168,25 @@ async def complete_event_assignment( assignment.proof_comment = comment - # Get marathon_id - marathon_id = assignment.challenge.game.marathon_id + # Get marathon_id and base points (handle playthrough vs regular challenge) + participant = assignment.participant + if assignment.is_playthrough and assignment.game: + marathon_id = assignment.game.marathon_id + base_points = assignment.game.playthrough_points or 0 + challenge_title = f"Прохождение: {assignment.game.title}" + game_title = assignment.game.title + difficulty = "medium" + else: + challenge = assignment.challenge + marathon_id = challenge.game.marathon_id + base_points = challenge.points + challenge_title = challenge.title + game_title = challenge.game.title + difficulty = challenge.difficulty # Get active event for bonus calculation active_event = await event_service.get_active_event(db, marathon_id) - # Calculate base points (no streak bonus for event assignments) - participant = assignment.participant - challenge = assignment.challenge - base_points = challenge.points - # Handle common enemy bonus common_enemy_bonus = 0 common_enemy_closed = False @@ -1114,12 +1210,13 @@ async def complete_event_assignment( # Log activity activity_data = { "assignment_id": assignment.id, - "game": challenge.game.title, - "challenge": challenge.title, - "difficulty": challenge.difficulty, + "game": game_title, + "challenge": challenge_title, + "difficulty": difficulty, "points": total_points, "event_type": EventType.COMMON_ENEMY.value, "is_event_assignment": True, + "is_playthrough": assignment.is_playthrough, } if common_enemy_bonus: activity_data["common_enemy_bonus"] = common_enemy_bonus diff --git a/backend/app/services/events.py b/backend/app/services/events.py index 51e6acb..6b45f14 100644 --- a/backend/app/services/events.py +++ b/backend/app/services/events.py @@ -47,6 +47,8 @@ class EventService: created_by_id: int | None = None, duration_minutes: int | None = None, challenge_id: int | None = None, + game_id: int | None = None, + is_playthrough: bool = False, ) -> Event: """Start a new event""" # Check no active event @@ -63,8 +65,12 @@ class EventService: # Build event data data = {} - if event_type == EventType.COMMON_ENEMY.value and challenge_id: - data["challenge_id"] = challenge_id + if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id): + if is_playthrough and game_id: + data["game_id"] = game_id + data["is_playthrough"] = True + else: + data["challenge_id"] = challenge_id data["completions"] = [] # Track who completed and when event = Event( @@ -79,9 +85,11 @@ class EventService: db.add(event) await db.flush() # Get event.id before committing - # Auto-assign challenge to all participants for Common Enemy - if event_type == EventType.COMMON_ENEMY.value and challenge_id: - await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id) + # Auto-assign challenge/playthrough to all participants for Common Enemy + if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id): + await self._assign_common_enemy_to_all( + db, marathon_id, event.id, challenge_id, game_id, is_playthrough + ) await db.commit() await db.refresh(event) @@ -105,7 +113,9 @@ class EventService: db: AsyncSession, marathon_id: int, event_id: int, - challenge_id: int, + challenge_id: int | None, + game_id: int | None = None, + is_playthrough: bool = False, ) -> None: """Create event assignments for all participants in the marathon""" # Get all participants @@ -118,7 +128,9 @@ class EventService: for participant in participants: assignment = Assignment( participant_id=participant.id, - challenge_id=challenge_id, + challenge_id=challenge_id if not is_playthrough else None, + game_id=game_id if is_playthrough else None, + is_playthrough=is_playthrough, status=AssignmentStatus.ACTIVE.value, event_type=EventType.COMMON_ENEMY.value, is_event_assignment=True, @@ -290,6 +302,30 @@ class EventService: ) return result.scalar_one_or_none() + async def get_common_enemy_game( + self, + db: AsyncSession, + event: Event + ): + """Get the playthrough game for common enemy event (if it's a playthrough)""" + from app.models import Game + + if event.type != EventType.COMMON_ENEMY.value: + return None + + data = event.data or {} + if not data.get("is_playthrough"): + return None + + game_id = data.get("game_id") + if not game_id: + return None + + result = await db.execute( + select(Game).where(Game.id == game_id) + ) + return result.scalar_one_or_none() + def get_time_remaining(self, event: Event | None) -> int | None: """Get remaining time in seconds for an event""" if not event or not event.end_time: