Common enemy rework

This commit is contained in:
2025-12-15 23:03:59 +07:00
parent 9a037cb34f
commit 07e02ce32d
11 changed files with 731 additions and 29 deletions

View File

@@ -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')

View File

@@ -10,12 +10,17 @@ from app.models import (
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
SwapRequest as SwapRequestModel, SwapRequestStatus, User, SwapRequest as SwapRequestModel, SwapRequestStatus, User,
) )
from fastapi import UploadFile, File, Form
from pathlib import Path
import uuid
from app.schemas import ( from app.schemas import (
EventCreate, EventResponse, ActiveEventResponse, EventEffects, EventCreate, EventResponse, ActiveEventResponse, EventEffects,
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate, MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests, 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.event import EVENT_INFO, COMMON_ENEMY_BONUSES
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.services.events import event_service from app.services.events import event_service
@@ -864,3 +869,259 @@ async def get_common_enemy_leaderboard(
) )
return 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
)

View File

@@ -38,7 +38,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip
return participant 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( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
@@ -47,6 +54,7 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None:
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.ACTIVE.value, Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == is_event,
) )
) )
return result.scalar_one_or_none() 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) participant = await get_participant_or_403(db, current_user.id, marathon_id)
# Check no active assignment # Check no active regular assignment (event assignments are separate)
active = await get_active_assignment(db, participant.id) active = await get_active_assignment(db, participant.id, is_event=False)
if active: if active:
raise HTTPException(status_code=400, detail="You already have an active assignment") 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 game = None
challenge = None challenge = None
# Handle special event cases # Handle special event cases (excluding Common Enemy - it has separate flow)
if active_event: if active_event:
if active_event.type == EventType.JACKPOT.value: if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only # 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() game = result.scalar_one_or_none()
# Consume jackpot (one-time use) # Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id) await event_service.consume_jackpot(db, active_event.id)
# Note: Common Enemy is handled separately via event-assignment endpoints
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
# Normal random selection if no special event handling # Normal random selection if no special event handling
if not game or not challenge: 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) @router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession): 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) 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: if not assignment:
return None return None
@@ -237,7 +235,7 @@ async def complete_assignment(
comment: str | None = Form(None), comment: str | None = Form(None),
proof_file: UploadFile | None = File(None), proof_file: UploadFile | None = File(None),
): ):
"""Complete an assignment with proof""" """Complete a regular assignment with proof (not event assignments)"""
# Get assignment # Get assignment
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
@@ -258,6 +256,10 @@ async def complete_assignment(
if assignment.status != AssignmentStatus.ACTIVE.value: if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active") 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 # Need either file or URL
if not proof_file and not proof_url: if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)") raise HTTPException(status_code=400, detail="Proof is required (file or URL)")

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -20,6 +20,8 @@ class Assignment(Base):
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE")) challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value) 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 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_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True) proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: 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 # Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")

View File

@@ -37,3 +37,4 @@ class Event(Base):
# Relationships # Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events") marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
created_by: Mapped["User | None"] = relationship("User") created_by: Mapped["User | None"] = relationship("User")
assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event")

View File

@@ -41,6 +41,7 @@ from app.schemas.assignment import (
SpinResult, SpinResult,
CompleteResult, CompleteResult,
DropResult, DropResult,
EventAssignmentResponse,
) )
from app.schemas.activity import ( from app.schemas.activity import (
ActivityResponse, ActivityResponse,
@@ -107,6 +108,7 @@ __all__ = [
"SpinResult", "SpinResult",
"CompleteResult", "CompleteResult",
"DropResult", "DropResult",
"EventAssignmentResponse",
# Activity # Activity
"ActivityResponse", "ActivityResponse",
"FeedResponse", "FeedResponse",

View File

@@ -48,3 +48,14 @@ class DropResult(BaseModel):
penalty: int penalty: int
total_points: int total_points: int
new_drop_count: 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

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
@@ -76,6 +76,12 @@ class EventService:
data=data if data else None, data=data if data else None,
) )
db.add(event) 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.commit()
await db.refresh(event) await db.refresh(event)
@@ -85,14 +91,58 @@ class EventService:
return event 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: 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)) result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
if event: if event:
event.is_active = False event.is_active = False
if not event.end_time: if not event.end_time:
event.end_time = datetime.utcnow() 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() await db.commit()
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None: async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:

View File

@@ -1,5 +1,5 @@
import client from './client' 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 = { export const eventsApi = {
getActive: async (marathonId: number): Promise<ActiveEvent> => { getActive: async (marathonId: number): Promise<ActiveEvent> => {
@@ -64,4 +64,27 @@ export const eventsApi = {
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`) const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
return response.data return response.data
}, },
// Event Assignment (Common Enemy)
getEventAssignment: async (marathonId: number): Promise<EventAssignment> => {
const response = await client.get<EventAssignment>(`/marathons/${marathonId}/event-assignment`)
return response.data
},
completeEventAssignment: async (
assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File }
): Promise<CompleteResult> => {
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<CompleteResult>(
`/event-assignments/${assignmentId}/complete`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
},
} }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api' 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 { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
@@ -41,7 +41,19 @@ export function PlayPage() {
// Common Enemy leaderboard state // Common Enemy leaderboard state
const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([]) const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([])
// Tab state for Common Enemy
type PlayTab = 'spin' | 'event'
const [activeTab, setActiveTab] = useState<PlayTab>('spin')
// Event assignment state (Common Enemy)
const [eventAssignment, setEventAssignment] = useState<EventAssignment | null>(null)
const [eventProofFile, setEventProofFile] = useState<File | null>(null)
const [eventProofUrl, setEventProofUrl] = useState('')
const [eventComment, setEventComment] = useState('')
const [isEventCompleting, setIsEventCompleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
loadData() loadData()
@@ -123,16 +135,18 @@ export function PlayPage() {
const loadData = async () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
const [marathonData, assignment, gamesData, eventData] = await Promise.all([ const [marathonData, assignment, gamesData, eventData, eventAssignmentData] = await Promise.all([
marathonsApi.get(parseInt(id)), marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'), gamesApi.list(parseInt(id), 'approved'),
eventsApi.getActive(parseInt(id)), eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
setGames(gamesData) setGames(gamesData)
setActiveEvent(eventData) setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
} catch (error) { } catch (error) {
console.error('Failed to load data:', error) console.error('Failed to load data:', error)
} finally { } 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) => { const handleRematch = async (assignmentId: number) => {
if (!id) return if (!id) return
@@ -367,8 +412,248 @@ export function PlayPage() {
</div> </div>
)} )}
{/* Common Enemy Leaderboard */} {/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && ( {activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">
<Button
variant={activeTab === 'spin' ? 'primary' : 'secondary'}
onClick={() => setActiveTab('spin')}
className="flex-1"
>
Мой прокрут
</Button>
<Button
variant={activeTab === 'event' ? 'primary' : 'secondary'}
onClick={() => setActiveTab('event')}
className="flex-1 relative"
>
Общий враг
{eventAssignment?.assignment && !eventAssignment.is_completed && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse" />
)}
</Button>
</div>
)}
{/* Event tab content (Common Enemy) */}
{activeTab === 'event' && activeEvent?.event?.type === 'common_enemy' && (
<>
{/* Common Enemy Leaderboard */}
<Card className="mb-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<Users className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-bold text-white">Выполнили челлендж</h3>
{commonEnemyLeaderboard.length > 0 && (
<span className="ml-auto text-gray-400 text-sm">
{commonEnemyLeaderboard.length} чел.
</span>
)}
</div>
{commonEnemyLeaderboard.length === 0 ? (
<div className="text-center py-4 text-gray-500">
Пока никто не выполнил. Будь первым!
</div>
) : (
<div className="space-y-2">
{commonEnemyLeaderboard.map((entry) => (
<div
key={entry.participant_id}
className={`
flex items-center gap-3 p-3 rounded-lg
${entry.rank === 1 ? 'bg-yellow-500/20 border border-yellow-500/30' :
entry.rank === 2 ? 'bg-gray-400/20 border border-gray-400/30' :
entry.rank === 3 ? 'bg-orange-600/20 border border-orange-600/30' :
'bg-gray-800'}
`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
${entry.rank === 1 ? 'bg-yellow-500 text-black' :
entry.rank === 2 ? 'bg-gray-400 text-black' :
entry.rank === 3 ? 'bg-orange-600 text-white' :
'bg-gray-700 text-gray-300'}
`}>
{entry.rank && entry.rank <= 3 ? (
<Trophy className="w-4 h-4" />
) : (
entry.rank
)}
</div>
<div className="flex-1">
<p className="text-white font-medium">{entry.user.nickname}</p>
</div>
{entry.bonus_points > 0 && (
<span className="text-green-400 text-sm font-medium">
+{entry.bonus_points} бонус
</span>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Event Assignment Card */}
{eventAssignment?.assignment && !eventAssignment.is_completed ? (
<Card>
<CardContent>
<div className="text-center mb-6">
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm">
Задание события "Общий враг"
</span>
</div>
{/* Game */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Игра</h3>
<p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title}
</p>
</div>
{/* Challenge */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Задание</h3>
<p className="text-xl font-bold text-white mb-2">
{eventAssignment.assignment.challenge.title}
</p>
<p className="text-gray-300">
{eventAssignment.assignment.challenge.description}
</p>
</div>
{/* Points */}
<div className="flex items-center gap-4 mb-6 text-sm">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full">
+{eventAssignment.assignment.challenge.points} очков
</span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full">
{eventAssignment.assignment.challenge.difficulty}
</span>
{eventAssignment.assignment.challenge.estimated_time && (
<span className="text-gray-400">
~{eventAssignment.assignment.challenge.estimated_time} мин
</span>
)}
</div>
{/* Proof hint */}
{eventAssignment.assignment.challenge.proof_hint && (
<div className="mb-6 p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400">
<strong>Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
</p>
</div>
)}
{/* Proof upload */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
</label>
{/* File upload */}
<input
ref={eventFileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => setEventProofFile(e.target.files?.[0] || null)}
/>
{eventProofFile ? (
<div className="flex items-center gap-2 p-3 bg-gray-900 rounded-lg">
<span className="text-white flex-1 truncate">{eventProofFile.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setEventProofFile(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Button
variant="secondary"
className="w-full"
onClick={() => eventFileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
)}
</div>
<div className="text-center text-gray-500">или</div>
{/* URL input */}
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value={eventProofUrl}
onChange={(e) => setEventProofUrl(e.target.value)}
/>
{/* Comment */}
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
value={eventComment}
onChange={(e) => setEventComment(e.target.value)}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
className="flex-1"
onClick={handleEventComplete}
isLoading={isEventCompleting}
disabled={!eventProofFile && !eventProofUrl}
>
Выполнено
</Button>
</div>
</CardContent>
</Card>
) : eventAssignment?.is_completed ? (
<Card>
<CardContent className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Задание выполнено!</h3>
<p className="text-gray-400">
Вы уже завершили челлендж события "Общий враг"
</p>
{eventAssignment.assignment && (
<p className="text-green-400 mt-2">
+{eventAssignment.assignment.points_earned} очков
</p>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="text-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-gray-500 mx-auto mb-4" />
<p className="text-gray-400">Загрузка задания события...</p>
</CardContent>
</Card>
)}
</>
)}
{/* Spin tab content - only show when spin tab is active or no common_enemy event */}
{(activeTab === 'spin' || activeEvent?.event?.type !== 'common_enemy') && (
<>
{/* Common Enemy Leaderboard - show on spin tab too for context */}
{activeEvent?.event?.type === 'common_enemy' && activeTab === 'spin' && commonEnemyLeaderboard.length > 0 && (
<Card className="mb-6"> <Card className="mb-6">
<CardContent> <CardContent>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
@@ -425,10 +710,10 @@ export function PlayPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* No active assignment - show spin wheel */} {/* No active assignment - show spin wheel */}
{!currentAssignment && ( {!currentAssignment && (
<> <>
<Card> <Card>
<CardContent className="py-8"> <CardContent className="py-8">
@@ -807,6 +1092,8 @@ export function PlayPage() {
)} )}
</> </>
)} )}
</>
)}
</div> </div>
) )
} }

View File

@@ -245,6 +245,14 @@ export interface CommonEnemyLeaderboardEntry {
bonus_points: number bonus_points: number
} }
// Event Assignment (Common Enemy)
export interface EventAssignment {
assignment: Assignment | null
event_id: number | null
challenge_id: number | null
is_completed: boolean
}
// Activity types // Activity types
export type ActivityType = export type ActivityType =
| 'join' | 'join'