diff --git a/backend/alembic/versions/008_rename_rematch_to_game_choice.py b/backend/alembic/versions/008_rename_rematch_to_game_choice.py new file mode 100644 index 0000000..5d63495 --- /dev/null +++ b/backend/alembic/versions/008_rename_rematch_to_game_choice.py @@ -0,0 +1,41 @@ +"""Rename rematch event type to game_choice + +Revision ID: 008_rename_to_game_choice +Revises: 007_add_event_assignment_fields +Create Date: 2024-12-15 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "008_rename_to_game_choice" +down_revision = "007_add_event_assignment_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Update event type from 'rematch' to 'game_choice' in events table + op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'") + + # Update event_type in assignments table + op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'") + + # Update activity data that references rematch event + op.execute(""" + UPDATE activities + SET data = jsonb_set(data, '{event_type}', '"game_choice"') + WHERE data->>'event_type' = 'rematch' + """) + + +def downgrade() -> None: + # Revert event type from 'game_choice' to 'rematch' + op.execute("UPDATE events SET type = 'rematch' WHERE type = 'game_choice'") + op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'") + op.execute(""" + UPDATE activities + SET data = jsonb_set(data, '{event_type}', '"rematch"') + WHERE data->>'event_type' = 'game_choice' + """) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index e49f771..1679d46 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -640,129 +640,173 @@ async def cancel_swap_request( return MessageResponse(message="Swap request cancelled") -@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse) -async def rematch_assignment( +# ==================== Game Choice Event Endpoints ==================== + + +class GameChoiceChallengeResponse(BaseModel): + """Challenge option for game choice event""" + id: int + title: str + description: str + difficulty: str + points: int + estimated_time: int | None + proof_type: str + proof_hint: str | None + + +class GameChoiceChallengesResponse(BaseModel): + """Response with available challenges for game choice""" + game_id: int + game_title: str + challenges: list[GameChoiceChallengeResponse] + + +class GameChoiceSelectRequest(BaseModel): + """Request to select a challenge during game choice event""" + challenge_id: int + + +@router.get("/marathons/{marathon_id}/game-choice/challenges", response_model=GameChoiceChallengesResponse) +async def get_game_choice_challenges( marathon_id: int, - assignment_id: int, + game_id: int, current_user: CurrentUser, db: DbSession, ): - """Retry a dropped assignment (during rematch event)""" + """Get 3 random challenges from a game for game choice event""" + from app.models import Game + from sqlalchemy.sql.expression import func + await get_marathon_or_404(db, marathon_id) participant = await require_participant(db, current_user.id, marathon_id) - # Check active rematch event + # Check active game_choice event event = await event_service.get_active_event(db, marathon_id) - if not event or event.type != EventType.REMATCH.value: - raise HTTPException(status_code=400, detail="No active rematch event") + if not event or event.type != EventType.GAME_CHOICE.value: + raise HTTPException(status_code=400, detail="No active game choice event") - # Check no current active assignment + # Get the game result = await db.execute( - select(Assignment).where( - Assignment.participant_id == participant.id, - Assignment.status == AssignmentStatus.ACTIVE.value, - ) + select(Game).where(Game.id == game_id, Game.marathon_id == marathon_id) ) - if result.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="You already have an active assignment") + game = result.scalar_one_or_none() + if not game: + raise HTTPException(status_code=404, detail="Game not found") - # Get the dropped assignment + # Get 3 random challenges from this game + result = await db.execute( + select(Challenge) + .where(Challenge.game_id == game_id) + .order_by(func.random()) + .limit(3) + ) + challenges = result.scalars().all() + + if not challenges: + raise HTTPException(status_code=400, detail="No challenges available for this game") + + return GameChoiceChallengesResponse( + game_id=game.id, + game_title=game.title, + challenges=[ + GameChoiceChallengeResponse( + id=c.id, + title=c.title, + description=c.description, + difficulty=c.difficulty, + points=c.points, + estimated_time=c.estimated_time, + proof_type=c.proof_type, + proof_hint=c.proof_hint, + ) + for c in challenges + ], + ) + + +@router.post("/marathons/{marathon_id}/game-choice/select", response_model=MessageResponse) +async def select_game_choice_challenge( + marathon_id: int, + data: GameChoiceSelectRequest, + current_user: CurrentUser, + db: DbSession, +): + """Select a challenge during game choice event (replaces current assignment if any)""" + await get_marathon_or_404(db, marathon_id) + participant = await require_participant(db, current_user.id, marathon_id) + + # Check active game_choice event + event = await event_service.get_active_event(db, marathon_id) + if not event or event.type != EventType.GAME_CHOICE.value: + raise HTTPException(status_code=400, detail="No active game choice event") + + # Get the challenge + result = await db.execute( + select(Challenge) + .options(selectinload(Challenge.game)) + .where(Challenge.id == data.challenge_id) + ) + challenge = result.scalar_one_or_none() + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + # Verify challenge belongs to this marathon + if challenge.game.marathon_id != marathon_id: + raise HTTPException(status_code=400, detail="Challenge does not belong to this marathon") + + # Check for current active assignment (non-event) result = await db.execute( select(Assignment) .options(selectinload(Assignment.challenge)) .where( - Assignment.id == assignment_id, Assignment.participant_id == participant.id, - Assignment.status == AssignmentStatus.DROPPED.value, + Assignment.status == AssignmentStatus.ACTIVE.value, + Assignment.is_event_assignment == False, ) ) - dropped = result.scalar_one_or_none() - if not dropped: - raise HTTPException(status_code=404, detail="Dropped assignment not found") + current_assignment = result.scalar_one_or_none() - # Create new assignment for the same challenge (with rematch event_type for 50% points) + # If there's a current assignment, replace it (free drop during this event) + old_challenge_title = None + if current_assignment: + old_challenge_title = current_assignment.challenge.title + # Mark old assignment as dropped (no penalty during game_choice event) + current_assignment.status = AssignmentStatus.DROPPED.value + current_assignment.completed_at = datetime.utcnow() + + # Create new assignment with chosen challenge new_assignment = Assignment( participant_id=participant.id, - challenge_id=dropped.challenge_id, + challenge_id=data.challenge_id, status=AssignmentStatus.ACTIVE.value, - event_type=EventType.REMATCH.value, + event_type=EventType.GAME_CHOICE.value, ) db.add(new_assignment) # Log activity + activity_data = { + "game": challenge.game.title, + "challenge": challenge.title, + "event_type": EventType.GAME_CHOICE.value, + } + if old_challenge_title: + activity_data["replaced_challenge"] = old_challenge_title + activity = Activity( marathon_id=marathon_id, user_id=current_user.id, - type=ActivityType.REMATCH.value, - data={ - "challenge": dropped.challenge.title, - "original_assignment_id": assignment_id, - }, + type=ActivityType.SPIN.value, # Treat as a spin activity + data=activity_data, ) db.add(activity) await db.commit() - return MessageResponse(message="Rematch started! Complete for 50% points") - - -class DroppedAssignmentResponse(BaseModel): - id: int - challenge: ChallengeResponse - dropped_at: datetime - - class Config: - from_attributes = True - - -@router.get("/marathons/{marathon_id}/dropped-assignments", response_model=list[DroppedAssignmentResponse]) -async def get_dropped_assignments( - marathon_id: int, - current_user: CurrentUser, - db: DbSession, -): - """Get dropped assignments that can be rematched""" - await get_marathon_or_404(db, marathon_id) - participant = await require_participant(db, current_user.id, marathon_id) - - result = await db.execute( - select(Assignment) - .options( - selectinload(Assignment.challenge).selectinload(Challenge.game) - ) - .where( - Assignment.participant_id == participant.id, - Assignment.status == AssignmentStatus.DROPPED.value, - ) - .order_by(Assignment.started_at.desc()) - ) - dropped = result.scalars().all() - - return [ - DroppedAssignmentResponse( - 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, - ), - is_generated=a.challenge.is_generated, - created_at=a.challenge.created_at, - ), - dropped_at=a.completed_at or a.started_at, - ) - for a in dropped - ] + if old_challenge_title: + return MessageResponse(message=f"Задание заменено! Теперь у вас: {challenge.title}") + else: + return MessageResponse(message=f"Задание выбрано: {challenge.title}") @router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate]) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 30bea85..1d61d80 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -307,12 +307,12 @@ async def complete_assignment( # Check active event for point multipliers active_event = await event_service.get_active_event(db, marathon_id) - # For jackpot/rematch: use the event_type stored in assignment (since event may be over) + # For jackpot: use the event_type stored in assignment (since event may be over) # For other events: use the currently active event effective_event = active_event - # Handle assignment-level event types (jackpot, rematch) - if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]: + # Handle assignment-level event types (jackpot) + if assignment.event_type == EventType.JACKPOT.value: # Create a mock event object for point calculation class MockEvent: def __init__(self, event_type): @@ -353,8 +353,8 @@ async def complete_assignment( "points": total_points, "streak": participant.current_streak, } - # Log event info (use assignment's event_type for jackpot/rematch, active_event for others) - if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]: + # Log event info (use assignment's event_type for jackpot, active_event for others) + if assignment.event_type == EventType.JACKPOT.value: activity_data["event_type"] = assignment.event_type activity_data["event_bonus"] = event_bonus elif active_event: diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py index be2c067..de7613b 100644 --- a/backend/app/models/activity.py +++ b/backend/app/models/activity.py @@ -19,7 +19,7 @@ class ActivityType(str, Enum): EVENT_START = "event_start" EVENT_END = "event_end" SWAP = "swap" - REMATCH = "rematch" + GAME_CHOICE = "game_choice" class Activity(Base): diff --git a/backend/app/models/event.py b/backend/app/models/event.py index af528e3..38b9db4 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -12,7 +12,7 @@ class EventType(str, Enum): DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков JACKPOT = "jackpot" # x3 за сложный челлендж SWAP = "swap" # обмен заданиями - REMATCH = "rematch" # реванш проваленного + GAME_CHOICE = "game_choice" # выбор игры (2-3 челленджа на выбор) class Event(Base): diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index ee96f48..b0a1e87 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -13,7 +13,7 @@ EventTypeLiteral = Literal[ "double_risk", "jackpot", "swap", - "rematch", + "game_choice", ] @@ -32,7 +32,7 @@ class EventCreate(BaseModel): class EventEffects(BaseModel): points_multiplier: float = 1.0 drop_free: bool = False - special_action: str | None = None # "swap", "rematch" + special_action: str | None = None # "swap", "game_choice" description: str = "" @@ -85,7 +85,7 @@ EVENT_INFO = { "drop_free": False, }, EventType.DOUBLE_RISK: { - "name": "Двойной риск", + "name": "Безопасная игра", "description": "Дропы бесплатны, но очки x0.5", "default_duration": 120, "points_multiplier": 0.5, @@ -106,13 +106,13 @@ EVENT_INFO = { "drop_free": False, "special_action": "swap", }, - EventType.REMATCH: { - "name": "Реванш", - "description": "Можно переделать проваленный челлендж за 50% очков", - "default_duration": 240, - "points_multiplier": 0.5, - "drop_free": False, - "special_action": "rematch", + EventType.GAME_CHOICE: { + "name": "Выбор игры", + "description": "Выбери игру и один из 3 челленджей. Можно заменить текущее задание без штрафа!", + "default_duration": 120, + "points_multiplier": 1.0, + "drop_free": True, # Free replacement of current assignment + "special_action": "game_choice", }, } diff --git a/backend/app/services/event_scheduler.py b/backend/app/services/event_scheduler.py index f657407..e4267da 100644 --- a/backend/app/services/event_scheduler.py +++ b/backend/app/services/event_scheduler.py @@ -21,7 +21,7 @@ AUTO_EVENT_TYPES = [ EventType.GOLDEN_HOUR, EventType.DOUBLE_RISK, EventType.JACKPOT, - EventType.REMATCH, + EventType.GAME_CHOICE, ] diff --git a/backend/app/services/points.py b/backend/app/services/points.py index 1f88f9f..62f4d1a 100644 --- a/backend/app/services/points.py +++ b/backend/app/services/points.py @@ -25,7 +25,7 @@ class PointsService: EventType.GOLDEN_HOUR.value: 1.5, EventType.DOUBLE_RISK.value: 0.5, EventType.JACKPOT.value: 3.0, - EventType.REMATCH.value: 0.5, + # GAME_CHOICE uses 1.0 multiplier (default) } def calculate_completion_points( diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts index c0ad751..64108c4 100644 --- a/frontend/src/api/events.ts +++ b/frontend/src/api/events.ts @@ -1,5 +1,5 @@ import client from './client' -import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult } from '@/types' +import type { ActiveEvent, MarathonEvent, EventCreate, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult, GameChoiceChallenges } from '@/types' export const eventsApi = { getActive: async (marathonId: number): Promise => { @@ -46,12 +46,18 @@ export const eventsApi = { await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`) }, - rematch: async (marathonId: number, assignmentId: number): Promise => { - await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`) + // Game Choice event + getGameChoiceChallenges: async (marathonId: number, gameId: number): Promise => { + const response = await client.get(`/marathons/${marathonId}/game-choice/challenges`, { + params: { game_id: gameId }, + }) + return response.data }, - getDroppedAssignments: async (marathonId: number): Promise => { - const response = await client.get(`/marathons/${marathonId}/dropped-assignments`) + selectGameChoiceChallenge: async (marathonId: number, challengeId: number): Promise<{ message: string }> => { + const response = await client.post<{ message: string }>(`/marathons/${marathonId}/game-choice/select`, { + challenge_id: challengeId, + }) return response.data }, diff --git a/frontend/src/components/EventBanner.tsx b/frontend/src/components/EventBanner.tsx index 0be17d4..8b023cf 100644 --- a/frontend/src/components/EventBanner.tsx +++ b/frontend/src/components/EventBanner.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react' +import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react' import type { ActiveEvent, EventType } from '@/types' import { EVENT_INFO } from '@/types' @@ -14,7 +14,7 @@ const EVENT_ICONS: Record = { double_risk: , jackpot: , swap: , - rematch: , + game_choice: , } const EVENT_COLORS: Record = { @@ -23,7 +23,7 @@ const EVENT_COLORS: Record = { double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400', jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400', swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400', - rematch: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400', + game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400', } function formatTime(seconds: number): string { diff --git a/frontend/src/components/EventControl.tsx b/frontend/src/components/EventControl.tsx index 008f00a..845a6e8 100644 --- a/frontend/src/components/EventControl.tsx +++ b/frontend/src/components/EventControl.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react' +import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react' import { Button } from '@/components/ui' import { eventsApi } from '@/api' import type { ActiveEvent, EventType, Challenge } from '@/types' @@ -17,7 +17,7 @@ const EVENT_TYPES: EventType[] = [ 'double_risk', 'jackpot', 'swap', - 'rematch', + 'game_choice', 'common_enemy', ] @@ -27,7 +27,7 @@ const EVENT_ICONS: Record = { double_risk: , jackpot: , swap: , - rematch: , + game_choice: , } export function EventControl({ diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index bf816f7..c73edee 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -1,11 +1,11 @@ import { useState, useEffect, useRef } from 'react' import { useParams, Link } from 'react-router-dom' import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api' -import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment } from '@/types' +import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges } from '@/types' import { Button, Card, CardContent } from '@/components/ui' import { SpinWheel } from '@/components/SpinWheel' import { EventBanner } from '@/components/EventBanner' -import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react' +import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react' export function PlayPage() { const { id } = useParams<{ id: string }>() @@ -26,10 +26,11 @@ export function PlayPage() { // Drop state const [isDropping, setIsDropping] = useState(false) - // Rematch state - const [droppedAssignments, setDroppedAssignments] = useState([]) - const [isRematchLoading, setIsRematchLoading] = useState(false) - const [rematchingId, setRematchingId] = useState(null) + // Game Choice state + const [selectedGameId, setSelectedGameId] = useState(null) + const [gameChoiceChallenges, setGameChoiceChallenges] = useState(null) + const [isLoadingChallenges, setIsLoadingChallenges] = useState(false) + const [isSelectingChallenge, setIsSelectingChallenge] = useState(false) // Swap state const [swapCandidates, setSwapCandidates] = useState([]) @@ -59,12 +60,13 @@ export function PlayPage() { loadData() }, [id]) - // Load dropped assignments when rematch event is active + // Reset game choice state when event changes or ends useEffect(() => { - if (activeEvent?.event?.type === 'rematch' && !currentAssignment) { - loadDroppedAssignments() + if (activeEvent?.event?.type !== 'game_choice') { + setSelectedGameId(null) + setGameChoiceChallenges(null) } - }, [activeEvent?.event?.type, currentAssignment]) + }, [activeEvent?.event?.type]) // Load swap candidates and requests when swap event is active useEffect(() => { @@ -86,16 +88,17 @@ export function PlayPage() { } }, [activeEvent?.event?.type]) - const loadDroppedAssignments = async () => { + const loadGameChoiceChallenges = async (gameId: number) => { if (!id) return - setIsRematchLoading(true) + setIsLoadingChallenges(true) try { - const dropped = await eventsApi.getDroppedAssignments(parseInt(id)) - setDroppedAssignments(dropped) + const challenges = await eventsApi.getGameChoiceChallenges(parseInt(id), gameId) + setGameChoiceChallenges(challenges) } catch (error) { - console.error('Failed to load dropped assignments:', error) + console.error('Failed to load game choice challenges:', error) + alert('Не удалось загрузить челленджи для этой игры') } finally { - setIsRematchLoading(false) + setIsLoadingChallenges(false) } } @@ -269,21 +272,33 @@ export function PlayPage() { } } - const handleRematch = async (assignmentId: number) => { + const handleGameSelect = async (gameId: number) => { + setSelectedGameId(gameId) + await loadGameChoiceChallenges(gameId) + } + + const handleChallengeSelect = async (challengeId: number) => { if (!id) return - if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return + const hasActiveAssignment = !!currentAssignment + const confirmMessage = hasActiveAssignment + ? 'Выбрать этот челлендж? Текущее задание будет заменено без штрафа.' + : 'Выбрать этот челлендж?' - setRematchingId(assignmentId) + if (!confirm(confirmMessage)) return + + setIsSelectingChallenge(true) try { - await eventsApi.rematch(parseInt(id), assignmentId) - alert('Реванш начат! Выполните задание за 50% очков.') + const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId) + alert(result.message) + setSelectedGameId(null) + setGameChoiceChallenges(null) await loadData() } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } - alert(error.response?.data?.detail || 'Не удалось начать реванш') + alert(error.response?.data?.detail || 'Не удалось выбрать челлендж') } finally { - setRematchingId(null) + setIsSelectingChallenge(false) } } @@ -654,122 +669,56 @@ export function PlayPage() { <> {/* Common Enemy Leaderboard - show on spin tab too for context */} {activeEvent?.event?.type === 'common_enemy' && activeTab === 'spin' && commonEnemyLeaderboard.length > 0 && ( - - -
- -

Выполнили челлендж

- {commonEnemyLeaderboard.length > 0 && ( - - {commonEnemyLeaderboard.length} чел. - - )} -
- - {commonEnemyLeaderboard.length === 0 ? ( -
- Пока никто не выполнил. Будь первым! -
- ) : ( -
- {commonEnemyLeaderboard.map((entry) => ( -
-
- {entry.rank && entry.rank <= 3 ? ( - - ) : ( - entry.rank - )} -
-
-

{entry.user.nickname}

-
- {entry.bonus_points > 0 && ( - - +{entry.bonus_points} бонус - - )} -
- ))} -
- )} -
-
- )} - - {/* No active assignment - show spin wheel */} - {!currentAssignment && ( - <> - - -

Крутите колесо!

-

- Получите случайную игру и задание для выполнения -

- -
-
- - {/* Rematch section - show during rematch event */} - {activeEvent?.event?.type === 'rematch' && droppedAssignments.length > 0 && ( - +
- -

Реванш

+ +

Выполнили челлендж

+ {commonEnemyLeaderboard.length > 0 && ( + + {commonEnemyLeaderboard.length} чел. + + )}
-

- Во время события "Реванш" вы можете повторить пропущенные задания за 50% очков -

- {isRematchLoading ? ( -
- + {commonEnemyLeaderboard.length === 0 ? ( +
+ Пока никто не выполнил. Будь первым!
) : ( -
- {droppedAssignments.map((dropped) => ( +
+ {commonEnemyLeaderboard.map((entry) => (
-
-

- {dropped.challenge.title} -

-

- {dropped.challenge.game.title} • {dropped.challenge.points * 0.5} очков -

+
+ {entry.rank && entry.rank <= 3 ? ( + + ) : ( + entry.rank + )}
- +
+

{entry.user.nickname}

+
+ {entry.bonus_points > 0 && ( + + +{entry.bonus_points} бонус + + )}
))}
@@ -777,8 +726,121 @@ export function PlayPage() { )} - - )} + + {/* Game Choice section - show ABOVE spin wheel during game_choice event (works with or without assignment) */} + {activeEvent?.event?.type === 'game_choice' && ( + + +
+ +

Выбор игры

+
+

+ Выберите игру и один из 3 челленджей. {currentAssignment ? 'Текущее задание будет заменено без штрафа!' : ''} +

+ + {/* Game selection */} + {!selectedGameId && ( +
+ {games.map((game) => ( + + ))} +
+ )} + + {/* Challenge selection */} + {selectedGameId && ( +
+
+

+ {gameChoiceChallenges?.game_title || 'Загрузка...'} +

+ +
+ + {isLoadingChallenges ? ( +
+ +
+ ) : gameChoiceChallenges?.challenges.length ? ( +
+ {gameChoiceChallenges.challenges.map((challenge) => ( +
+
+
+

{challenge.title}

+

{challenge.description}

+
+ + +{challenge.points} очков + + + {challenge.difficulty} + + {challenge.estimated_time && ( + ~{challenge.estimated_time} мин + )} +
+
+ +
+
+ ))} +
+ ) : ( +

+ Нет доступных челленджей для этой игры +

+ )} +
+ )} +
+
+ )} + + {/* No active assignment - show spin wheel */} + {!currentAssignment && ( + + +

Крутите колесо!

+

+ Получите случайную игру и задание для выполнения +

+ +
+
+ )} {/* Active assignment */} {currentAssignment && ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f6a6dae..0a3d44d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -290,7 +290,7 @@ export type EventType = | 'double_risk' | 'jackpot' | 'swap' - | 'rematch' + | 'game_choice' export interface MarathonEvent { id: number @@ -334,7 +334,7 @@ export const EVENT_INFO: Record = { golden_hour: 'Золотой час', common_enemy: 'Общий враг', - double_risk: 'Двойной риск', + double_risk: 'Безопасная игра', jackpot: 'Джекпот', swap: 'Обмен', - rematch: 'Реванш', + game_choice: 'Выбор игры', } // Difficulty translation