From 07e02ce32da2395b537c3b6d46befa7b39e09e2a Mon Sep 17 00:00:00 2001 From: Oronemu Date: Mon, 15 Dec 2025 23:03:59 +0700 Subject: [PATCH] Common enemy rework --- .../007_add_event_assignment_fields.py | 54 ++++ backend/app/api/v1/events.py | 263 ++++++++++++++- backend/app/api/v1/wheel.py | 38 +-- backend/app/models/assignment.py | 5 +- backend/app/models/event.py | 1 + backend/app/schemas/__init__.py | 2 + backend/app/schemas/assignment.py | 11 + backend/app/services/events.py | 54 +++- frontend/src/api/events.ts | 25 +- frontend/src/pages/PlayPage.tsx | 299 +++++++++++++++++- frontend/src/types/index.ts | 8 + 11 files changed, 731 insertions(+), 29 deletions(-) create mode 100644 backend/alembic/versions/007_add_event_assignment_fields.py diff --git a/backend/alembic/versions/007_add_event_assignment_fields.py b/backend/alembic/versions/007_add_event_assignment_fields.py new file mode 100644 index 0000000..a009aa4 --- /dev/null +++ b/backend/alembic/versions/007_add_event_assignment_fields.py @@ -0,0 +1,54 @@ +"""Add is_event_assignment and event_id to assignments for Common Enemy support + +Revision ID: 007_add_event_assignment_fields +Revises: 006_add_swap_requests +Create Date: 2024-12-15 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '007_add_event_assignment_fields' +down_revision: Union[str, None] = '006_add_swap_requests' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add is_event_assignment column with default False + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('assignments')] + + if 'is_event_assignment' not in columns: + op.add_column( + 'assignments', + sa.Column('is_event_assignment', sa.Boolean(), nullable=False, server_default=sa.false()) + ) + op.create_index('ix_assignments_is_event_assignment', 'assignments', ['is_event_assignment']) + + if 'event_id' not in columns: + op.add_column( + 'assignments', + sa.Column('event_id', sa.Integer(), nullable=True) + ) + op.create_foreign_key( + 'fk_assignments_event_id', + 'assignments', + 'events', + ['event_id'], + ['id'], + ondelete='SET NULL' + ) + op.create_index('ix_assignments_event_id', 'assignments', ['event_id']) + + +def downgrade() -> None: + op.drop_index('ix_assignments_event_id', table_name='assignments') + op.drop_constraint('fk_assignments_event_id', 'assignments', type_='foreignkey') + op.drop_column('assignments', 'event_id') + op.drop_index('ix_assignments_is_event_assignment', table_name='assignments') + op.drop_column('assignments', 'is_event_assignment') diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index ce2a4e5..e49f771 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -10,12 +10,17 @@ from app.models import ( Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, SwapRequest as SwapRequestModel, SwapRequestStatus, User, ) +from fastapi import UploadFile, File, Form +from pathlib import Path +import uuid + from app.schemas import ( EventCreate, EventResponse, ActiveEventResponse, EventEffects, MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate, SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests, - CommonEnemyLeaderboard, + CommonEnemyLeaderboard, EventAssignmentResponse, AssignmentResponse, CompleteResult, ) +from app.core.config import settings from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.user import UserPublic from app.services.events import event_service @@ -864,3 +869,259 @@ async def get_common_enemy_leaderboard( ) return leaderboard + + +# ==================== Event Assignment Endpoints ==================== + + +def assignment_to_response(assignment: Assignment) -> AssignmentResponse: + """Convert Assignment model to AssignmentResponse""" + challenge = assignment.challenge + game = challenge.game + 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=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + ), + is_generated=challenge.is_generated, + created_at=challenge.created_at, + ), + status=assignment.status, + proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" 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, + ) + + +@router.get("/marathons/{marathon_id}/event-assignment", response_model=EventAssignmentResponse) +async def get_event_assignment( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Get current user's event assignment (Common Enemy)""" + await get_marathon_or_404(db, marathon_id) + participant = await require_participant(db, current_user.id, marathon_id) + + # Get active common enemy event + event = await event_service.get_active_event(db, marathon_id) + + # Find event assignment for this participant + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.challenge).selectinload(Challenge.game) + ) + .where( + Assignment.participant_id == participant.id, + Assignment.is_event_assignment == True, + ) + .order_by(Assignment.started_at.desc()) + ) + assignment = result.scalar_one_or_none() + + # Check if completed + is_completed = assignment.status == AssignmentStatus.COMPLETED.value if assignment else False + + # If no active event but we have an assignment, it might be from a past event + # Only return it if the event is still active + if not event or event.type != EventType.COMMON_ENEMY.value: + # Check if assignment belongs to an inactive event + if assignment and assignment.event_id: + result = await db.execute( + select(Event).where(Event.id == assignment.event_id) + ) + assignment_event = result.scalar_one_or_none() + if assignment_event and not assignment_event.is_active: + # Event ended, don't return the assignment + return EventAssignmentResponse( + assignment=None, + event_id=None, + challenge_id=None, + is_completed=False, + ) + + 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, + is_completed=is_completed, + ) + + +@router.post("/event-assignments/{assignment_id}/complete", response_model=CompleteResult) +async def complete_event_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), +): + """Complete an event assignment (Common Enemy) with proof""" + from app.services.points import PointsService + points_service = PointsService() + + # Get assignment + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.participant), + selectinload(Assignment.challenge).selectinload(Challenge.game), + ) + .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") + + # Must be event assignment + if not assignment.is_event_assignment: + raise HTTPException(status_code=400, detail="This is not an event assignment") + + # Need either file or URL + if not proof_file and not proof_url: + raise HTTPException(status_code=400, detail="Proof is required (file or URL)") + + # Handle file upload + if proof_file: + contents = await proof_file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + ) + + ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" + if ext not in settings.ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", + ) + + filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" + filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename + filepath.parent.mkdir(parents=True, exist_ok=True) + + with open(filepath, "wb") as f: + f.write(contents) + + assignment.proof_path = str(filepath) + else: + assignment.proof_url = proof_url + + assignment.proof_comment = comment + + # Get marathon_id + marathon_id = assignment.challenge.game.marathon_id + + # 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 + 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 = base_points + common_enemy_bonus + + # Update assignment + assignment.status = AssignmentStatus.COMPLETED.value + assignment.points_earned = total_points + assignment.completed_at = datetime.utcnow() + + # Update participant points (event assignments add to total but don't affect streak) + participant.total_points += total_points + + # Log activity + activity_data = { + "game": challenge.game.title, + "challenge": challenge.title, + "difficulty": challenge.difficulty, + "points": total_points, + "event_type": EventType.COMMON_ENEMY.value, + "is_event_assignment": True, + } + 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: + # Load winner nicknames + 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 + ] + + 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() + + return CompleteResult( + points_earned=total_points, + streak_bonus=0, # Event assignments don't give streak bonus + total_points=participant.total_points, + new_streak=participant.current_streak, # Streak unchanged + ) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index c2e6215..30bea85 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -38,7 +38,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip return participant -async def get_active_assignment(db, participant_id: int) -> Assignment | None: +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( @@ -47,6 +54,7 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None: .where( Assignment.participant_id == participant_id, Assignment.status == AssignmentStatus.ACTIVE.value, + Assignment.is_event_assignment == is_event, ) ) return result.scalar_one_or_none() @@ -66,8 +74,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) participant = await get_participant_or_403(db, current_user.id, marathon_id) - # Check no active assignment - active = await get_active_assignment(db, participant.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") @@ -77,7 +85,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) game = None challenge = None - # Handle special event cases + # Handle special event cases (excluding Common Enemy - it has separate flow) if active_event: if active_event.type == EventType.JACKPOT.value: # Jackpot: Get hard challenge only @@ -90,17 +98,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) game = result.scalar_one_or_none() # Consume jackpot (one-time use) await event_service.consume_jackpot(db, active_event.id) - - elif active_event.type == EventType.COMMON_ENEMY.value: - # Common enemy: Everyone gets same challenge (if not already completed) - event_data = active_event.data or {} - completions = event_data.get("completions", []) - already_completed = any(c["participant_id"] == participant.id for c in completions) - - if not already_completed: - challenge = await event_service.get_common_enemy_challenge(db, active_event) - if challenge: - game = challenge.game + # Note: Common Enemy is handled separately via event-assignment endpoints # Normal random selection if no special event handling if not game or not challenge: @@ -192,9 +190,9 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) @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 assignment""" + """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) + assignment = await get_active_assignment(db, participant.id, is_event=False) if not assignment: return None @@ -237,7 +235,7 @@ async def complete_assignment( comment: str | None = Form(None), proof_file: UploadFile | None = File(None), ): - """Complete an assignment with proof""" + """Complete a regular assignment with proof (not event assignments)""" # Get assignment result = await db.execute( select(Assignment) @@ -258,6 +256,10 @@ async def complete_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") + # Need either file or URL if not proof_file and not proof_url: raise HTTPException(status_code=400, detail="Proof is required (file or URL)") diff --git a/backend/app/models/assignment.py b/backend/app/models/assignment.py index 212ccd1..21a9bb0 100644 --- a/backend/app/models/assignment.py +++ b/backend/app/models/assignment.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from sqlalchemy import String, Text, DateTime, ForeignKey, Integer +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -20,6 +20,8 @@ class Assignment(Base): challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE")) status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value) event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created + is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments + event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True) proof_url: Mapped[str | None] = mapped_column(Text, nullable=True) proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -31,3 +33,4 @@ class Assignment(Base): # Relationships participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") + event: Mapped["Event | None"] = relationship("Event", back_populates="assignments") diff --git a/backend/app/models/event.py b/backend/app/models/event.py index 353e436..af528e3 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -37,3 +37,4 @@ class Event(Base): # Relationships marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events") created_by: Mapped["User | None"] = relationship("User") + assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index b36bec9..69415de 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -41,6 +41,7 @@ from app.schemas.assignment import ( SpinResult, CompleteResult, DropResult, + EventAssignmentResponse, ) from app.schemas.activity import ( ActivityResponse, @@ -107,6 +108,7 @@ __all__ = [ "SpinResult", "CompleteResult", "DropResult", + "EventAssignmentResponse", # Activity "ActivityResponse", "FeedResponse", diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index 7f499bf..c90f0a9 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -48,3 +48,14 @@ class DropResult(BaseModel): penalty: int total_points: int new_drop_count: int + + +class EventAssignmentResponse(BaseModel): + """Response for event-specific assignment (Common Enemy)""" + assignment: AssignmentResponse | None + event_id: int | None + challenge_id: int | None + is_completed: bool + + class Config: + from_attributes = True diff --git a/backend/app/services/events.py b/backend/app/services/events.py index 74483c9..90fe663 100644 --- a/backend/app/services/events.py +++ b/backend/app/services/events.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import selectinload from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.ext.asyncio import AsyncSession -from app.models import Event, EventType, Marathon, Challenge, Difficulty +from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES @@ -76,6 +76,12 @@ class EventService: data=data if data else None, ) 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) + await db.commit() await db.refresh(event) @@ -85,14 +91,58 @@ class EventService: return event + async def _assign_common_enemy_to_all( + self, + db: AsyncSession, + marathon_id: int, + event_id: int, + challenge_id: int, + ) -> None: + """Create event assignments for all participants in the marathon""" + # Get all participants + result = await db.execute( + select(Participant).where(Participant.marathon_id == marathon_id) + ) + participants = result.scalars().all() + + # Create event assignment for each participant + for participant in participants: + assignment = Assignment( + participant_id=participant.id, + challenge_id=challenge_id, + status=AssignmentStatus.ACTIVE.value, + event_type=EventType.COMMON_ENEMY.value, + is_event_assignment=True, + event_id=event_id, + ) + db.add(assignment) + async def end_event(self, db: AsyncSession, event_id: int) -> None: - """End an event""" + """End an event and mark incomplete event assignments as expired""" + from sqlalchemy import update + result = await db.execute(select(Event).where(Event.id == event_id)) event = result.scalar_one_or_none() if event: event.is_active = False if not event.end_time: event.end_time = datetime.utcnow() + + # Mark all incomplete event assignments for this event as dropped + if event.type == EventType.COMMON_ENEMY.value: + await db.execute( + update(Assignment) + .where( + Assignment.event_id == event_id, + Assignment.is_event_assignment == True, + Assignment.status == AssignmentStatus.ACTIVE.value, + ) + .values( + status=AssignmentStatus.DROPPED.value, + completed_at=datetime.utcnow(), + ) + ) + await db.commit() async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None: diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts index ba96756..c0ad751 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 } from '@/types' +import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult } from '@/types' export const eventsApi = { getActive: async (marathonId: number): Promise => { @@ -64,4 +64,27 @@ export const eventsApi = { const response = await client.get(`/marathons/${marathonId}/common-enemy-leaderboard`) return response.data }, + + // Event Assignment (Common Enemy) + getEventAssignment: async (marathonId: number): Promise => { + const response = await client.get(`/marathons/${marathonId}/event-assignment`) + return response.data + }, + + completeEventAssignment: async ( + assignmentId: number, + data: { proof_url?: string; comment?: string; proof_file?: File } + ): Promise => { + const formData = new FormData() + if (data.proof_url) formData.append('proof_url', data.proof_url) + if (data.comment) formData.append('comment', data.comment) + if (data.proof_file) formData.append('proof_file', data.proof_file) + + const response = await client.post( + `/event-assignments/${assignmentId}/complete`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } } + ) + return response.data + }, } diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index 21f874f..bf816f7 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -1,7 +1,7 @@ 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 } from '@/types' +import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment } from '@/types' import { Button, Card, CardContent } from '@/components/ui' import { SpinWheel } from '@/components/SpinWheel' import { EventBanner } from '@/components/EventBanner' @@ -41,7 +41,19 @@ export function PlayPage() { // Common Enemy leaderboard state const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState([]) + // Tab state for Common Enemy + type PlayTab = 'spin' | 'event' + const [activeTab, setActiveTab] = useState('spin') + + // Event assignment state (Common Enemy) + const [eventAssignment, setEventAssignment] = useState(null) + const [eventProofFile, setEventProofFile] = useState(null) + const [eventProofUrl, setEventProofUrl] = useState('') + const [eventComment, setEventComment] = useState('') + const [isEventCompleting, setIsEventCompleting] = useState(false) + const fileInputRef = useRef(null) + const eventFileInputRef = useRef(null) useEffect(() => { loadData() @@ -123,16 +135,18 @@ export function PlayPage() { const loadData = async () => { if (!id) return try { - const [marathonData, assignment, gamesData, eventData] = await Promise.all([ + const [marathonData, assignment, gamesData, eventData, eventAssignmentData] = await Promise.all([ marathonsApi.get(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)), gamesApi.list(parseInt(id), 'approved'), eventsApi.getActive(parseInt(id)), + eventsApi.getEventAssignment(parseInt(id)), ]) setMarathon(marathonData) setCurrentAssignment(assignment) setGames(gamesData) setActiveEvent(eventData) + setEventAssignment(eventAssignmentData) } catch (error) { console.error('Failed to load data:', error) } finally { @@ -224,6 +238,37 @@ export function PlayPage() { } } + const handleEventComplete = async () => { + if (!eventAssignment?.assignment) return + if (!eventProofFile && !eventProofUrl) { + alert('Пожалуйста, предоставьте доказательство (файл или ссылку)') + return + } + + setIsEventCompleting(true) + try { + const result = await eventsApi.completeEventAssignment(eventAssignment.assignment.id, { + proof_file: eventProofFile || undefined, + proof_url: eventProofUrl || undefined, + comment: eventComment || undefined, + }) + + alert(`Выполнено! +${result.points_earned} очков`) + + // Reset form + setEventProofFile(null) + setEventProofUrl('') + setEventComment('') + + await loadData() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + alert(error.response?.data?.detail || 'Не удалось выполнить') + } finally { + setIsEventCompleting(false) + } + } + const handleRematch = async (assignmentId: number) => { if (!id) return @@ -367,8 +412,248 @@ export function PlayPage() { )} - {/* Common Enemy Leaderboard */} + {/* Tabs for Common Enemy event */} {activeEvent?.event?.type === 'common_enemy' && ( +
+ + +
+ )} + + {/* Event tab content (Common Enemy) */} + {activeTab === 'event' && activeEvent?.event?.type === 'common_enemy' && ( + <> + {/* Common Enemy Leaderboard */} + + +
+ +

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

+ {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} бонус + + )} +
+ ))} +
+ )} +
+
+ + {/* Event Assignment Card */} + {eventAssignment?.assignment && !eventAssignment.is_completed ? ( + + +
+ + Задание события "Общий враг" + +
+ + {/* Game */} +
+

Игра

+

+ {eventAssignment.assignment.challenge.game.title} +

+
+ + {/* Challenge */} +
+

Задание

+

+ {eventAssignment.assignment.challenge.title} +

+

+ {eventAssignment.assignment.challenge.description} +

+
+ + {/* Points */} +
+ + +{eventAssignment.assignment.challenge.points} очков + + + {eventAssignment.assignment.challenge.difficulty} + + {eventAssignment.assignment.challenge.estimated_time && ( + + ~{eventAssignment.assignment.challenge.estimated_time} мин + + )} +
+ + {/* Proof hint */} + {eventAssignment.assignment.challenge.proof_hint && ( +
+

+ Нужно доказательство: {eventAssignment.assignment.challenge.proof_hint} +

+
+ )} + + {/* Proof upload */} +
+
+ + + {/* File upload */} + setEventProofFile(e.target.files?.[0] || null)} + /> + + {eventProofFile ? ( +
+ {eventProofFile.name} + +
+ ) : ( + + )} +
+ +
или
+ + {/* URL input */} + setEventProofUrl(e.target.value)} + /> + + {/* Comment */} +