Compare commits
2 Commits
5db2f9c48d
...
4239ea8516
| Author | SHA1 | Date | |
|---|---|---|---|
| 4239ea8516 | |||
| 1a882fb2e0 |
60
backend/alembic/versions/004_add_events.py
Normal file
60
backend/alembic/versions/004_add_events.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Add events table and auto_events_enabled to marathons
|
||||
|
||||
Revision ID: 004_add_events
|
||||
Revises: 003_create_admin
|
||||
Create Date: 2024-12-14
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '004_add_events'
|
||||
down_revision: Union[str, None] = '003_create_admin'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create events table if it doesn't exist
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'events' not in tables:
|
||||
op.create_table(
|
||||
'events',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('marathon_id', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(30), nullable=False),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=False),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
)
|
||||
|
||||
# Create index if it doesn't exist
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes('events')]
|
||||
if 'ix_events_marathon_id' not in indexes:
|
||||
op.create_index('ix_events_marathon_id', 'events', ['marathon_id'])
|
||||
|
||||
# Add auto_events_enabled to marathons if it doesn't exist
|
||||
columns = [col['name'] for col in inspector.get_columns('marathons')]
|
||||
if 'auto_events_enabled' not in columns:
|
||||
op.add_column(
|
||||
'marathons',
|
||||
sa.Column('auto_events_enabled', sa.Boolean(), nullable=False, server_default='true')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('marathons', 'auto_events_enabled')
|
||||
op.drop_index('ix_events_marathon_id', table_name='events')
|
||||
op.drop_table('events')
|
||||
34
backend/alembic/versions/005_add_assignment_event_type.py
Normal file
34
backend/alembic/versions/005_add_assignment_event_type.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Add event_type to assignments
|
||||
|
||||
Revision ID: 005_add_assignment_event_type
|
||||
Revises: 004_add_events
|
||||
Create Date: 2024-12-14
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '005_add_assignment_event_type'
|
||||
down_revision: Union[str, None] = '004_add_events'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add event_type column to assignments if it doesn't exist
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('assignments')]
|
||||
|
||||
if 'event_type' not in columns:
|
||||
op.add_column(
|
||||
'assignments',
|
||||
sa.Column('event_type', sa.String(30), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('assignments', 'event_type')
|
||||
54
backend/alembic/versions/006_add_swap_requests.py
Normal file
54
backend/alembic/versions/006_add_swap_requests.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Add swap_requests table for two-sided swap confirmation
|
||||
|
||||
Revision ID: 006_add_swap_requests
|
||||
Revises: 005_assignment_event
|
||||
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 = '006_add_swap_requests'
|
||||
down_revision: Union[str, None] = '005_add_assignment_event_type'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create swap_requests table if it doesn't exist
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'swap_requests' not in tables:
|
||||
op.create_table(
|
||||
'swap_requests',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||
sa.Column('from_participant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('to_participant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('from_assignment_id', sa.Integer(), nullable=False),
|
||||
sa.Column('to_assignment_id', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('responded_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['from_participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['to_participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['from_assignment_id'], ['assignments.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['to_assignment_id'], ['assignments.id'], ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('ix_swap_requests_event_id', 'swap_requests', ['event_id'])
|
||||
op.create_index('ix_swap_requests_from_participant_id', 'swap_requests', ['from_participant_id'])
|
||||
op.create_index('ix_swap_requests_to_participant_id', 'swap_requests', ['to_participant_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_swap_requests_to_participant_id', table_name='swap_requests')
|
||||
op.drop_index('ix_swap_requests_from_participant_id', table_name='swap_requests')
|
||||
op.drop_index('ix_swap_requests_event_id', table_name='swap_requests')
|
||||
op.drop_table('swap_requests')
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -12,3 +12,4 @@ router.include_router(challenges.router)
|
||||
router.include_router(wheel.router)
|
||||
router.include_router(feed.router)
|
||||
router.include_router(admin.router)
|
||||
router.include_router(events.router)
|
||||
|
||||
@@ -81,6 +81,52 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
||||
]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List all challenges for a marathon (from all approved games). Participants only."""
|
||||
# Check marathon exists
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
# Check user is participant or admin
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not current_user.is_admin and not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Get all challenges from approved games in this marathon
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
)
|
||||
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [
|
||||
ChallengeResponse(
|
||||
id=c.id,
|
||||
title=c.title,
|
||||
description=c.description,
|
||||
type=c.type,
|
||||
difficulty=c.difficulty,
|
||||
points=c.points,
|
||||
estimated_time=c.estimated_time,
|
||||
proof_type=c.proof_type,
|
||||
proof_hint=c.proof_hint,
|
||||
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
|
||||
is_generated=c.is_generated,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
for c in challenges
|
||||
]
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||
async def create_challenge(
|
||||
game_id: int,
|
||||
|
||||
866
backend/app/api/v1/events.py
Normal file
866
backend/app/api/v1/events.py
Normal file
@@ -0,0 +1,866 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, and_, or_
|
||||
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,
|
||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||
)
|
||||
from app.schemas import (
|
||||
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
|
||||
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
|
||||
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
|
||||
CommonEnemyLeaderboard,
|
||||
)
|
||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
from app.schemas.user import UserPublic
|
||||
from app.services.events import event_service
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
|
||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
return marathon
|
||||
|
||||
|
||||
async def require_organizer(db, user_id: int, marathon_id: int) -> Participant:
|
||||
result = await db.execute(
|
||||
select(Participant).where(
|
||||
Participant.user_id == user_id,
|
||||
Participant.marathon_id == marathon_id,
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="Not a participant")
|
||||
if participant.role != ParticipantRole.ORGANIZER.value:
|
||||
raise HTTPException(status_code=403, detail="Only organizers can manage events")
|
||||
return participant
|
||||
|
||||
|
||||
async def require_participant(db, user_id: int, marathon_id: int) -> Participant:
|
||||
result = await db.execute(
|
||||
select(Participant).where(
|
||||
Participant.user_id == user_id,
|
||||
Participant.marathon_id == marathon_id,
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="Not a participant")
|
||||
return participant
|
||||
|
||||
|
||||
def event_to_response(event: Event) -> EventResponse:
|
||||
return EventResponse(
|
||||
id=event.id,
|
||||
type=event.type,
|
||||
start_time=event.start_time,
|
||||
end_time=event.end_time,
|
||||
is_active=event.is_active,
|
||||
created_by=UserPublic(
|
||||
id=event.created_by.id,
|
||||
login=event.created_by.login,
|
||||
nickname=event.created_by.nickname,
|
||||
avatar_url=None,
|
||||
role=event.created_by.role,
|
||||
created_at=event.created_by.created_at,
|
||||
) if event.created_by else None,
|
||||
data=event.data,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/event", response_model=ActiveEventResponse)
|
||||
async def get_active_event(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession
|
||||
):
|
||||
"""Get currently active event for marathon"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
effects = event_service.get_event_effects(event)
|
||||
time_remaining = event_service.get_time_remaining(event)
|
||||
|
||||
return ActiveEventResponse(
|
||||
event=event_to_response(event) if event else None,
|
||||
effects=effects,
|
||||
time_remaining_seconds=time_remaining,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/events", response_model=list[EventResponse])
|
||||
async def list_events(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""Get event history for marathon"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Event)
|
||||
.options(selectinload(Event.created_by))
|
||||
.where(Event.marathon_id == marathon_id)
|
||||
.order_by(Event.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
return [event_to_response(e) for e in events]
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/events", response_model=EventResponse)
|
||||
async def start_event(
|
||||
marathon_id: int,
|
||||
data: EventCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Start a new event (organizer only)"""
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
await require_organizer(db, current_user.id, marathon_id)
|
||||
|
||||
if marathon.status != MarathonStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is not active")
|
||||
|
||||
# Validate common_enemy requires challenge_id
|
||||
if data.type == EventType.COMMON_ENEMY.value and not data.challenge_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Common enemy event requires challenge_id"
|
||||
)
|
||||
|
||||
try:
|
||||
event = await event_service.start_event(
|
||||
db=db,
|
||||
marathon_id=marathon_id,
|
||||
event_type=data.type,
|
||||
created_by_id=current_user.id,
|
||||
duration_minutes=data.duration_minutes,
|
||||
challenge_id=data.challenge_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Log activity
|
||||
event_info = EVENT_INFO.get(EventType(data.type), {})
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.EVENT_START.value,
|
||||
data={
|
||||
"event_type": data.type,
|
||||
"event_name": event_info.get("name", data.type),
|
||||
},
|
||||
)
|
||||
db.add(activity)
|
||||
await db.commit()
|
||||
|
||||
# Reload with relationship
|
||||
await db.refresh(event, ["created_by"])
|
||||
|
||||
return event_to_response(event)
|
||||
|
||||
|
||||
@router.delete("/marathons/{marathon_id}/event", response_model=MessageResponse)
|
||||
async def stop_event(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Stop active event (organizer only)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_organizer(db, current_user.id, marathon_id)
|
||||
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="No active event")
|
||||
|
||||
# Build activity data before ending event
|
||||
event_info = EVENT_INFO.get(EventType(event.type), {})
|
||||
activity_data = {
|
||||
"event_type": event.type,
|
||||
"event_name": event_info.get("name", event.type),
|
||||
"auto_closed": False,
|
||||
}
|
||||
|
||||
# For common_enemy, include winners in activity
|
||||
if event.type == EventType.COMMON_ENEMY.value:
|
||||
event_data = event.data or {}
|
||||
completions = event_data.get("completions", [])
|
||||
if completions:
|
||||
activity_data["winners"] = [
|
||||
{
|
||||
"user_id": c["user_id"],
|
||||
"rank": c["rank"],
|
||||
"bonus_points": COMMON_ENEMY_BONUSES.get(c["rank"], 0),
|
||||
}
|
||||
for c in completions[:3] # Top 3
|
||||
]
|
||||
|
||||
await event_service.end_event(db, event.id)
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.EVENT_END.value,
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Event stopped")
|
||||
|
||||
|
||||
def build_swap_request_response(
|
||||
swap_req: SwapRequestModel,
|
||||
) -> SwapRequestResponse:
|
||||
"""Build SwapRequestResponse from model with loaded relationships"""
|
||||
return SwapRequestResponse(
|
||||
id=swap_req.id,
|
||||
status=swap_req.status,
|
||||
from_user=UserPublic(
|
||||
id=swap_req.from_participant.user.id,
|
||||
login=swap_req.from_participant.user.login,
|
||||
nickname=swap_req.from_participant.user.nickname,
|
||||
avatar_url=None,
|
||||
role=swap_req.from_participant.user.role,
|
||||
created_at=swap_req.from_participant.user.created_at,
|
||||
),
|
||||
to_user=UserPublic(
|
||||
id=swap_req.to_participant.user.id,
|
||||
login=swap_req.to_participant.user.login,
|
||||
nickname=swap_req.to_participant.user.nickname,
|
||||
avatar_url=None,
|
||||
role=swap_req.to_participant.user.role,
|
||||
created_at=swap_req.to_participant.user.created_at,
|
||||
),
|
||||
from_challenge=SwapRequestChallengeInfo(
|
||||
title=swap_req.from_assignment.challenge.title,
|
||||
description=swap_req.from_assignment.challenge.description,
|
||||
points=swap_req.from_assignment.challenge.points,
|
||||
difficulty=swap_req.from_assignment.challenge.difficulty,
|
||||
game_title=swap_req.from_assignment.challenge.game.title,
|
||||
),
|
||||
to_challenge=SwapRequestChallengeInfo(
|
||||
title=swap_req.to_assignment.challenge.title,
|
||||
description=swap_req.to_assignment.challenge.description,
|
||||
points=swap_req.to_assignment.challenge.points,
|
||||
difficulty=swap_req.to_assignment.challenge.difficulty,
|
||||
game_title=swap_req.to_assignment.challenge.game.title,
|
||||
),
|
||||
created_at=swap_req.created_at,
|
||||
responded_at=swap_req.responded_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/swap-requests", response_model=SwapRequestResponse)
|
||||
async def create_swap_request(
|
||||
marathon_id: int,
|
||||
data: SwapRequestCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a swap request to another participant (requires their confirmation)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
raise HTTPException(status_code=400, detail="No active swap event")
|
||||
|
||||
# Get target participant
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(
|
||||
Participant.id == data.target_participant_id,
|
||||
Participant.marathon_id == marathon_id,
|
||||
)
|
||||
)
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target participant not found")
|
||||
|
||||
if target.id == participant.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
|
||||
|
||||
# Get both active assignments
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
my_assignment = result.scalar_one_or_none()
|
||||
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == target.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
target_assignment = result.scalar_one_or_none()
|
||||
|
||||
if not my_assignment or not target_assignment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Both participants must have active assignments to swap"
|
||||
)
|
||||
|
||||
# Check if there's already a pending request between these participants
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel).where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
or_(
|
||||
and_(
|
||||
SwapRequestModel.from_participant_id == participant.id,
|
||||
SwapRequestModel.to_participant_id == target.id,
|
||||
),
|
||||
and_(
|
||||
SwapRequestModel.from_participant_id == target.id,
|
||||
SwapRequestModel.to_participant_id == participant.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A pending swap request already exists between you and this participant"
|
||||
)
|
||||
|
||||
# Create swap request
|
||||
swap_request = SwapRequestModel(
|
||||
event_id=event.id,
|
||||
from_participant_id=participant.id,
|
||||
to_participant_id=target.id,
|
||||
from_assignment_id=my_assignment.id,
|
||||
to_assignment_id=target_assignment.id,
|
||||
status=SwapRequestStatus.PENDING.value,
|
||||
)
|
||||
db.add(swap_request)
|
||||
await db.commit()
|
||||
await db.refresh(swap_request)
|
||||
|
||||
# Load relationships for response
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.options(
|
||||
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.to_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
)
|
||||
.where(SwapRequestModel.id == swap_request.id)
|
||||
)
|
||||
swap_request = result.scalar_one()
|
||||
|
||||
return build_swap_request_response(swap_request)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/swap-requests", response_model=MySwapRequests)
|
||||
async def get_my_swap_requests(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get current user's incoming and outgoing swap requests"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
return MySwapRequests(incoming=[], outgoing=[])
|
||||
|
||||
# Get all pending requests for this event involving this participant
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.options(
|
||||
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.to_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
)
|
||||
.where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
or_(
|
||||
SwapRequestModel.from_participant_id == participant.id,
|
||||
SwapRequestModel.to_participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
.order_by(SwapRequestModel.created_at.desc())
|
||||
)
|
||||
requests = result.scalars().all()
|
||||
|
||||
incoming = []
|
||||
outgoing = []
|
||||
for req in requests:
|
||||
response = build_swap_request_response(req)
|
||||
if req.to_participant_id == participant.id:
|
||||
incoming.append(response)
|
||||
else:
|
||||
outgoing.append(response)
|
||||
|
||||
return MySwapRequests(incoming=incoming, outgoing=outgoing)
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/swap-requests/{request_id}/accept", response_model=MessageResponse)
|
||||
async def accept_swap_request(
|
||||
marathon_id: int,
|
||||
request_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Accept a swap request (performs the actual swap)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
raise HTTPException(status_code=400, detail="No active swap event")
|
||||
|
||||
# Get the swap request
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.options(
|
||||
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.to_participant),
|
||||
selectinload(SwapRequestModel.from_assignment),
|
||||
selectinload(SwapRequestModel.to_assignment),
|
||||
)
|
||||
.where(
|
||||
SwapRequestModel.id == request_id,
|
||||
SwapRequestModel.event_id == event.id,
|
||||
)
|
||||
)
|
||||
swap_request = result.scalar_one_or_none()
|
||||
if not swap_request:
|
||||
raise HTTPException(status_code=404, detail="Swap request not found")
|
||||
|
||||
# Check that current user is the target
|
||||
if swap_request.to_participant_id != participant.id:
|
||||
raise HTTPException(status_code=403, detail="You can only accept requests sent to you")
|
||||
|
||||
if swap_request.status != SwapRequestStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="This request is no longer pending")
|
||||
|
||||
# Verify both assignments are still active
|
||||
result = await db.execute(
|
||||
select(Assignment).where(Assignment.id == swap_request.from_assignment_id)
|
||||
)
|
||||
from_assignment = result.scalar_one_or_none()
|
||||
|
||||
result = await db.execute(
|
||||
select(Assignment).where(Assignment.id == swap_request.to_assignment_id)
|
||||
)
|
||||
to_assignment = result.scalar_one_or_none()
|
||||
|
||||
if not from_assignment or not to_assignment:
|
||||
swap_request.status = SwapRequestStatus.CANCELLED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="One or both assignments no longer exist")
|
||||
|
||||
if from_assignment.status != AssignmentStatus.ACTIVE.value or to_assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
swap_request.status = SwapRequestStatus.CANCELLED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
|
||||
|
||||
# Perform the swap
|
||||
from_challenge_id = from_assignment.challenge_id
|
||||
from_assignment.challenge_id = to_assignment.challenge_id
|
||||
to_assignment.challenge_id = from_challenge_id
|
||||
|
||||
# Update request status
|
||||
swap_request.status = SwapRequestStatus.ACCEPTED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
|
||||
# Cancel any other pending requests involving these participants in this event
|
||||
await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
SwapRequestModel.id != request_id,
|
||||
or_(
|
||||
SwapRequestModel.from_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
SwapRequestModel.to_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
)
|
||||
)
|
||||
)
|
||||
# Update those to cancelled
|
||||
from sqlalchemy import update
|
||||
await db.execute(
|
||||
update(SwapRequestModel)
|
||||
.where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
SwapRequestModel.id != request_id,
|
||||
or_(
|
||||
SwapRequestModel.from_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
SwapRequestModel.to_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
)
|
||||
)
|
||||
.values(status=SwapRequestStatus.CANCELLED.value, responded_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.SWAP.value,
|
||||
data={
|
||||
"swapped_with_user_id": swap_request.from_participant.user_id,
|
||||
"swapped_with_nickname": swap_request.from_participant.user.nickname,
|
||||
},
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Swap completed successfully!")
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/swap-requests/{request_id}/decline", response_model=MessageResponse)
|
||||
async def decline_swap_request(
|
||||
marathon_id: int,
|
||||
request_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Decline a swap request"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event (allow declining even if event ended)
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
# Get the swap request
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel).where(SwapRequestModel.id == request_id)
|
||||
)
|
||||
swap_request = result.scalar_one_or_none()
|
||||
if not swap_request:
|
||||
raise HTTPException(status_code=404, detail="Swap request not found")
|
||||
|
||||
# Check that current user is the target
|
||||
if swap_request.to_participant_id != participant.id:
|
||||
raise HTTPException(status_code=403, detail="You can only decline requests sent to you")
|
||||
|
||||
if swap_request.status != SwapRequestStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="This request is no longer pending")
|
||||
|
||||
# Update status
|
||||
swap_request.status = SwapRequestStatus.DECLINED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Swap request declined")
|
||||
|
||||
|
||||
@router.delete("/marathons/{marathon_id}/swap-requests/{request_id}", response_model=MessageResponse)
|
||||
async def cancel_swap_request(
|
||||
marathon_id: int,
|
||||
request_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Cancel your own outgoing swap request"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Get the swap request
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel).where(SwapRequestModel.id == request_id)
|
||||
)
|
||||
swap_request = result.scalar_one_or_none()
|
||||
if not swap_request:
|
||||
raise HTTPException(status_code=404, detail="Swap request not found")
|
||||
|
||||
# Check that current user is the sender
|
||||
if swap_request.from_participant_id != participant.id:
|
||||
raise HTTPException(status_code=403, detail="You can only cancel your own requests")
|
||||
|
||||
if swap_request.status != SwapRequestStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="This request is no longer pending")
|
||||
|
||||
# Update status
|
||||
swap_request.status = SwapRequestStatus.CANCELLED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Swap request cancelled")
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse)
|
||||
async def rematch_assignment(
|
||||
marathon_id: int,
|
||||
assignment_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Retry a dropped assignment (during rematch event)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active rematch 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")
|
||||
|
||||
# Check no current active assignment
|
||||
result = await db.execute(
|
||||
select(Assignment).where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||
|
||||
# Get the dropped assignment
|
||||
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,
|
||||
)
|
||||
)
|
||||
dropped = result.scalar_one_or_none()
|
||||
if not dropped:
|
||||
raise HTTPException(status_code=404, detail="Dropped assignment not found")
|
||||
|
||||
# Create new assignment for the same challenge (with rematch event_type for 50% points)
|
||||
new_assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=dropped.challenge_id,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=EventType.REMATCH.value,
|
||||
)
|
||||
db.add(new_assignment)
|
||||
|
||||
# Log activity
|
||||
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,
|
||||
},
|
||||
)
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
|
||||
async def get_swap_candidates(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get participants with active assignments available for swap (during swap event)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
raise HTTPException(status_code=400, detail="No active swap event")
|
||||
|
||||
# Get all participants except current user with active assignments
|
||||
from app.models import Game
|
||||
result = await db.execute(
|
||||
select(Participant, Assignment, Challenge, Game)
|
||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||
.join(Challenge, Assignment.challenge_id == Challenge.id)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.id != participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
login=p.user.login,
|
||||
nickname=p.user.nickname,
|
||||
avatar_url=None,
|
||||
role=p.user.role,
|
||||
created_at=p.user.created_at,
|
||||
),
|
||||
challenge_title=challenge.title,
|
||||
challenge_description=challenge.description,
|
||||
challenge_points=challenge.points,
|
||||
challenge_difficulty=challenge.difficulty,
|
||||
game_title=game.title,
|
||||
)
|
||||
for p, assignment, challenge, game in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
|
||||
async def get_common_enemy_leaderboard(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get leaderboard for common enemy event (who completed the challenge)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Get active common enemy event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.COMMON_ENEMY.value:
|
||||
return []
|
||||
|
||||
# Get completions from event data
|
||||
data = event.data or {}
|
||||
completions = data.get("completions", [])
|
||||
|
||||
if not completions:
|
||||
return []
|
||||
|
||||
# Get user info for all participants who completed
|
||||
user_ids = [c["user_id"] for c in completions]
|
||||
result = await db.execute(
|
||||
select(User).where(User.id.in_(user_ids))
|
||||
)
|
||||
users_by_id = {u.id: u for u in result.scalars().all()}
|
||||
|
||||
# Build leaderboard
|
||||
leaderboard = []
|
||||
for completion in completions:
|
||||
user = users_by_id.get(completion["user_id"])
|
||||
if user:
|
||||
leaderboard.append(
|
||||
CommonEnemyLeaderboard(
|
||||
participant_id=completion["participant_id"],
|
||||
user=UserPublic(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
avatar_url=None,
|
||||
role=user.role,
|
||||
created_at=user.created_at,
|
||||
),
|
||||
completed_at=completion.get("completed_at"),
|
||||
rank=completion.get("rank"),
|
||||
bonus_points=COMMON_ENEMY_BONUSES.get(completion.get("rank", 0), 0),
|
||||
)
|
||||
)
|
||||
|
||||
return leaderboard
|
||||
@@ -170,6 +170,7 @@ async def create_marathon(
|
||||
invite_code=marathon.invite_code,
|
||||
is_public=marathon.is_public,
|
||||
game_proposal_mode=marathon.game_proposal_mode,
|
||||
auto_events_enabled=marathon.auto_events_enabled,
|
||||
start_date=marathon.start_date,
|
||||
end_date=marathon.end_date,
|
||||
participants_count=1,
|
||||
@@ -206,6 +207,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
|
||||
invite_code=marathon.invite_code,
|
||||
is_public=marathon.is_public,
|
||||
game_proposal_mode=marathon.game_proposal_mode,
|
||||
auto_events_enabled=marathon.auto_events_enabled,
|
||||
start_date=marathon.start_date,
|
||||
end_date=marathon.end_date,
|
||||
participants_count=participants_count,
|
||||
@@ -240,6 +242,8 @@ async def update_marathon(
|
||||
marathon.is_public = data.is_public
|
||||
if data.game_proposal_mode is not None:
|
||||
marathon.game_proposal_mode = data.game_proposal_mode
|
||||
if data.auto_events_enabled is not None:
|
||||
marathon.auto_events_enabled = data.auto_events_enabled
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -10,13 +10,15 @@ from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.config import settings
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType
|
||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||
EventType, Difficulty
|
||||
)
|
||||
from app.schemas import (
|
||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
||||
)
|
||||
from app.services.points import PointsService
|
||||
from app.services.events import event_service
|
||||
|
||||
router = APIRouter(tags=["wheel"])
|
||||
|
||||
@@ -69,7 +71,39 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
if active:
|
||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||
|
||||
# Get all games with challenges
|
||||
# Check active event
|
||||
active_event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
game = None
|
||||
challenge = None
|
||||
|
||||
# Handle special event cases
|
||||
if active_event:
|
||||
if active_event.type == EventType.JACKPOT.value:
|
||||
# Jackpot: Get hard challenge only
|
||||
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
||||
if challenge:
|
||||
# Load game for challenge
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == challenge.game_id)
|
||||
)
|
||||
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
|
||||
|
||||
# Normal random selection if no special event handling
|
||||
if not game or not challenge:
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
@@ -80,35 +114,48 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
if not games:
|
||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||||
|
||||
# Random selection
|
||||
game = random.choice(games)
|
||||
challenge = random.choice(game.challenges)
|
||||
|
||||
# Create assignment
|
||||
# Create assignment (store event_type for jackpot multiplier on completion)
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=challenge.id,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=active_event.type if active_event else None,
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"challenge": challenge.title,
|
||||
}
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.SPIN.value,
|
||||
data={
|
||||
"game": game.title,
|
||||
"challenge": challenge.title,
|
||||
},
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
|
||||
# Calculate drop penalty
|
||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
||||
# Calculate drop penalty (considers active event for double_risk)
|
||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
||||
|
||||
# Get challenges count (avoid lazy loading in async context)
|
||||
challenges_count = 0
|
||||
if 'challenges' in game.__dict__:
|
||||
challenges_count = len(game.challenges)
|
||||
else:
|
||||
challenges_count = await db.scalar(
|
||||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||
)
|
||||
|
||||
return SpinResult(
|
||||
assignment_id=assignment.id,
|
||||
@@ -119,7 +166,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
download_url=game.download_url,
|
||||
genre=game.genre,
|
||||
added_by=None,
|
||||
challenges_count=len(game.challenges),
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
),
|
||||
challenge=ChallengeResponse(
|
||||
@@ -246,9 +293,41 @@ async def complete_assignment(
|
||||
participant = assignment.participant
|
||||
challenge = assignment.challenge
|
||||
|
||||
total_points, streak_bonus = points_service.calculate_completion_points(
|
||||
challenge.points, participant.current_streak
|
||||
# Get marathon_id for activity and event check
|
||||
result = await db.execute(
|
||||
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
||||
)
|
||||
full_challenge = result.scalar_one()
|
||||
marathon_id = full_challenge.game.marathon_id
|
||||
|
||||
# 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 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]:
|
||||
# Create a mock event object for point calculation
|
||||
class MockEvent:
|
||||
def __init__(self, event_type):
|
||||
self.type = event_type
|
||||
effective_event = MockEvent(assignment.event_type)
|
||||
|
||||
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
|
||||
challenge.points, participant.current_streak, effective_event
|
||||
)
|
||||
|
||||
# Handle common enemy bonus
|
||||
common_enemy_bonus = 0
|
||||
common_enemy_closed = False
|
||||
common_enemy_winners = None
|
||||
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
|
||||
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
|
||||
db, active_event, participant.id, current_user.id
|
||||
)
|
||||
total_points += common_enemy_bonus
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
@@ -261,25 +340,53 @@ async def complete_assignment(
|
||||
participant.current_streak += 1
|
||||
participant.drop_count = 0 # Reset drop counter on success
|
||||
|
||||
# Get marathon_id for activity
|
||||
result = await db.execute(
|
||||
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
||||
)
|
||||
full_challenge = result.scalar_one()
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=full_challenge.game.marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.COMPLETE.value,
|
||||
data={
|
||||
activity_data = {
|
||||
"challenge": challenge.title,
|
||||
"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]:
|
||||
activity_data["event_type"] = assignment.event_type
|
||||
activity_data["event_bonus"] = event_bonus
|
||||
elif active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
activity_data["event_bonus"] = event_bonus
|
||||
if common_enemy_bonus:
|
||||
activity_data["common_enemy_bonus"] = common_enemy_bonus
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.COMPLETE.value,
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
# If common enemy event auto-closed, log the event end with winners
|
||||
if common_enemy_closed and common_enemy_winners:
|
||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
event_end_activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id, # Last completer triggers the close
|
||||
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": [
|
||||
{
|
||||
"user_id": w["user_id"],
|
||||
"rank": w["rank"],
|
||||
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
||||
}
|
||||
for w in common_enemy_winners
|
||||
],
|
||||
},
|
||||
)
|
||||
db.add(event_end_activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return CompleteResult(
|
||||
@@ -314,9 +421,13 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||
|
||||
participant = assignment.participant
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Calculate penalty
|
||||
penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
||||
# Check active event for free drops (double_risk)
|
||||
active_event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
# Calculate penalty (0 if double_risk event is active)
|
||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
@@ -328,14 +439,20 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
participant.drop_count += 1
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=assignment.challenge.game.marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.DROP.value,
|
||||
data={
|
||||
activity_data = {
|
||||
"challenge": assignment.challenge.title,
|
||||
"penalty": penalty,
|
||||
},
|
||||
}
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
if active_event.type == EventType.DOUBLE_RISK.value:
|
||||
activity_data["free_drop"] = True
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.DROP.value,
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine, Base
|
||||
from app.core.database import engine, Base, async_session_maker
|
||||
from app.api.v1 import router as api_router
|
||||
from app.services.event_scheduler import event_scheduler
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -22,9 +23,13 @@ async def lifespan(app: FastAPI):
|
||||
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
|
||||
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Start event scheduler
|
||||
await event_scheduler.start(async_session_maker)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await event_scheduler.stop()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from app.models.game import Game, GameStatus
|
||||
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||
from app.models.assignment import Assignment, AssignmentStatus
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.models.event import Event, EventType
|
||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -24,4 +26,8 @@ __all__ = [
|
||||
"AssignmentStatus",
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"Event",
|
||||
"EventType",
|
||||
"SwapRequest",
|
||||
"SwapRequestStatus",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,10 @@ class ActivityType(str, Enum):
|
||||
ADD_GAME = "add_game"
|
||||
APPROVE_GAME = "approve_game"
|
||||
REJECT_GAME = "reject_game"
|
||||
EVENT_START = "event_start"
|
||||
EVENT_END = "event_end"
|
||||
SWAP = "swap"
|
||||
REMATCH = "rematch"
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
|
||||
@@ -19,6 +19,7 @@ class Assignment(Base):
|
||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
|
||||
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
|
||||
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)
|
||||
|
||||
39
backend/app/models/event.py
Normal file
39
backend/app/models/event.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, DateTime, ForeignKey, JSON, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
GOLDEN_HOUR = "golden_hour" # x1.5 очков
|
||||
COMMON_ENEMY = "common_enemy" # общий челлендж для всех
|
||||
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
|
||||
JACKPOT = "jackpot" # x3 за сложный челлендж
|
||||
SWAP = "swap" # обмен заданиями
|
||||
REMATCH = "rematch" # реванш проваленного
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
marathon_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("marathons.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
|
||||
created_by: Mapped["User | None"] = relationship("User")
|
||||
@@ -30,6 +30,7 @@ class Marathon(Base):
|
||||
game_proposal_mode: Mapped[str] = mapped_column(String(20), default=GameProposalMode.ALL_PARTICIPANTS.value)
|
||||
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
@@ -53,3 +54,8 @@ class Marathon(Base):
|
||||
back_populates="marathon",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
events: Mapped[list["Event"]] = relationship(
|
||||
"Event",
|
||||
back_populates="marathon",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
62
backend/app/models/swap_request.py
Normal file
62
backend/app/models/swap_request.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SwapRequestStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
ACCEPTED = "accepted"
|
||||
DECLINED = "declined"
|
||||
CANCELLED = "cancelled" # Cancelled by requester or event ended
|
||||
|
||||
|
||||
class SwapRequest(Base):
|
||||
__tablename__ = "swap_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
event_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("events.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
from_participant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("participants.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
to_participant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("participants.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
from_assignment_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("assignments.id", ondelete="CASCADE")
|
||||
)
|
||||
to_assignment_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("assignments.id", ondelete="CASCADE")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=SwapRequestStatus.PENDING.value
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
responded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
event: Mapped["Event"] = relationship("Event")
|
||||
from_participant: Mapped["Participant"] = relationship(
|
||||
"Participant",
|
||||
foreign_keys=[from_participant_id]
|
||||
)
|
||||
to_participant: Mapped["Participant"] = relationship(
|
||||
"Participant",
|
||||
foreign_keys=[to_participant_id]
|
||||
)
|
||||
from_assignment: Mapped["Assignment"] = relationship(
|
||||
"Assignment",
|
||||
foreign_keys=[from_assignment_id]
|
||||
)
|
||||
to_assignment: Mapped["Assignment"] = relationship(
|
||||
"Assignment",
|
||||
foreign_keys=[to_assignment_id]
|
||||
)
|
||||
@@ -46,6 +46,21 @@ from app.schemas.activity import (
|
||||
ActivityResponse,
|
||||
FeedResponse,
|
||||
)
|
||||
from app.schemas.event import (
|
||||
EventCreate,
|
||||
EventResponse,
|
||||
EventEffects,
|
||||
ActiveEventResponse,
|
||||
SwapRequest,
|
||||
SwapCandidate,
|
||||
CommonEnemyLeaderboard,
|
||||
EVENT_INFO,
|
||||
COMMON_ENEMY_BONUSES,
|
||||
SwapRequestCreate,
|
||||
SwapRequestResponse,
|
||||
SwapRequestChallengeInfo,
|
||||
MySwapRequests,
|
||||
)
|
||||
from app.schemas.common import (
|
||||
MessageResponse,
|
||||
ErrorResponse,
|
||||
@@ -95,6 +110,20 @@ __all__ = [
|
||||
# Activity
|
||||
"ActivityResponse",
|
||||
"FeedResponse",
|
||||
# Event
|
||||
"EventCreate",
|
||||
"EventResponse",
|
||||
"EventEffects",
|
||||
"ActiveEventResponse",
|
||||
"SwapRequest",
|
||||
"SwapCandidate",
|
||||
"CommonEnemyLeaderboard",
|
||||
"EVENT_INFO",
|
||||
"COMMON_ENEMY_BONUSES",
|
||||
"SwapRequestCreate",
|
||||
"SwapRequestResponse",
|
||||
"SwapRequestChallengeInfo",
|
||||
"MySwapRequests",
|
||||
# Common
|
||||
"MessageResponse",
|
||||
"ErrorResponse",
|
||||
|
||||
174
backend/app/schemas/event.py
Normal file
174
backend/app/schemas/event.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
from app.models.event import EventType
|
||||
from app.schemas.user import UserPublic
|
||||
|
||||
|
||||
# Event type literals for Pydantic
|
||||
EventTypeLiteral = Literal[
|
||||
"golden_hour",
|
||||
"common_enemy",
|
||||
"double_risk",
|
||||
"jackpot",
|
||||
"swap",
|
||||
"rematch",
|
||||
]
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
type: EventTypeLiteral
|
||||
duration_minutes: int | None = Field(
|
||||
None,
|
||||
description="Duration in minutes. If not provided, uses default for event type."
|
||||
)
|
||||
challenge_id: int | None = Field(
|
||||
None,
|
||||
description="For common_enemy event - the challenge everyone will get"
|
||||
)
|
||||
|
||||
|
||||
class EventEffects(BaseModel):
|
||||
points_multiplier: float = 1.0
|
||||
drop_free: bool = False
|
||||
special_action: str | None = None # "swap", "rematch"
|
||||
description: str = ""
|
||||
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
id: int
|
||||
type: EventTypeLiteral
|
||||
start_time: datetime
|
||||
end_time: datetime | None
|
||||
is_active: bool
|
||||
created_by: UserPublic | None
|
||||
data: dict | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ActiveEventResponse(BaseModel):
|
||||
event: EventResponse | None
|
||||
effects: EventEffects
|
||||
time_remaining_seconds: int | None = None
|
||||
|
||||
|
||||
class SwapRequest(BaseModel):
|
||||
target_participant_id: int
|
||||
|
||||
|
||||
class CommonEnemyLeaderboard(BaseModel):
|
||||
participant_id: int
|
||||
user: UserPublic
|
||||
completed_at: datetime | None
|
||||
rank: int | None
|
||||
bonus_points: int
|
||||
|
||||
|
||||
# Event descriptions and default durations
|
||||
EVENT_INFO = {
|
||||
EventType.GOLDEN_HOUR: {
|
||||
"name": "Золотой час",
|
||||
"description": "Все очки x1.5!",
|
||||
"default_duration": 45,
|
||||
"points_multiplier": 1.5,
|
||||
"drop_free": False,
|
||||
},
|
||||
EventType.COMMON_ENEMY: {
|
||||
"name": "Общий враг",
|
||||
"description": "Все получают одинаковый челлендж. Первые 3 получают бонус!",
|
||||
"default_duration": None, # Until all complete
|
||||
"points_multiplier": 1.0,
|
||||
"drop_free": False,
|
||||
},
|
||||
EventType.DOUBLE_RISK: {
|
||||
"name": "Двойной риск",
|
||||
"description": "Дропы бесплатны, но очки x0.5",
|
||||
"default_duration": 120,
|
||||
"points_multiplier": 0.5,
|
||||
"drop_free": True,
|
||||
},
|
||||
EventType.JACKPOT: {
|
||||
"name": "Джекпот",
|
||||
"description": "Следующий спин — сложный челлендж с x3 очками!",
|
||||
"default_duration": None, # 1 spin
|
||||
"points_multiplier": 3.0,
|
||||
"drop_free": False,
|
||||
},
|
||||
EventType.SWAP: {
|
||||
"name": "Обмен",
|
||||
"description": "Можно поменяться заданием с другим участником",
|
||||
"default_duration": 60,
|
||||
"points_multiplier": 1.0,
|
||||
"drop_free": False,
|
||||
"special_action": "swap",
|
||||
},
|
||||
EventType.REMATCH: {
|
||||
"name": "Реванш",
|
||||
"description": "Можно переделать проваленный челлендж за 50% очков",
|
||||
"default_duration": 240,
|
||||
"points_multiplier": 0.5,
|
||||
"drop_free": False,
|
||||
"special_action": "rematch",
|
||||
},
|
||||
}
|
||||
|
||||
# Bonus points for Common Enemy top 3
|
||||
COMMON_ENEMY_BONUSES = {
|
||||
1: 50,
|
||||
2: 30,
|
||||
3: 15,
|
||||
}
|
||||
|
||||
|
||||
class SwapCandidate(BaseModel):
|
||||
"""Participant available for assignment swap"""
|
||||
participant_id: int
|
||||
user: UserPublic
|
||||
challenge_title: str
|
||||
challenge_description: str
|
||||
challenge_points: int
|
||||
challenge_difficulty: str
|
||||
game_title: str
|
||||
|
||||
|
||||
# Two-sided swap confirmation schemas
|
||||
SwapRequestStatusLiteral = Literal["pending", "accepted", "declined", "cancelled"]
|
||||
|
||||
|
||||
class SwapRequestCreate(BaseModel):
|
||||
"""Request to swap assignment with another participant"""
|
||||
target_participant_id: int
|
||||
|
||||
|
||||
class SwapRequestChallengeInfo(BaseModel):
|
||||
"""Challenge info for swap request display"""
|
||||
title: str
|
||||
description: str
|
||||
points: int
|
||||
difficulty: str
|
||||
game_title: str
|
||||
|
||||
|
||||
class SwapRequestResponse(BaseModel):
|
||||
"""Response for a swap request"""
|
||||
id: int
|
||||
status: SwapRequestStatusLiteral
|
||||
from_user: UserPublic
|
||||
to_user: UserPublic
|
||||
from_challenge: SwapRequestChallengeInfo
|
||||
to_challenge: SwapRequestChallengeInfo
|
||||
created_at: datetime
|
||||
responded_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MySwapRequests(BaseModel):
|
||||
"""User's incoming and outgoing swap requests"""
|
||||
incoming: list[SwapRequestResponse]
|
||||
outgoing: list[SwapRequestResponse]
|
||||
@@ -22,6 +22,7 @@ class MarathonUpdate(BaseModel):
|
||||
start_date: datetime | None = None
|
||||
is_public: bool | None = None
|
||||
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
||||
auto_events_enabled: bool | None = None
|
||||
|
||||
|
||||
class ParticipantInfo(BaseModel):
|
||||
@@ -47,6 +48,7 @@ class MarathonResponse(MarathonBase):
|
||||
invite_code: str
|
||||
is_public: bool
|
||||
game_proposal_mode: str
|
||||
auto_events_enabled: bool
|
||||
start_date: datetime | None
|
||||
end_date: datetime | None
|
||||
participants_count: int
|
||||
|
||||
150
backend/app/services/event_scheduler.py
Normal file
150
backend/app/services/event_scheduler.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Event Scheduler for automatic event launching in marathons.
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Marathon, MarathonStatus, Event, EventType
|
||||
from app.services.events import EventService
|
||||
|
||||
|
||||
# Configuration
|
||||
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
||||
EVENT_PROBABILITY = 0.1 # 10% chance per check to start an event
|
||||
MIN_EVENT_GAP_HOURS = 4 # Minimum hours between events
|
||||
|
||||
# Events that can be auto-triggered (excluding common_enemy which needs a challenge_id)
|
||||
AUTO_EVENT_TYPES = [
|
||||
EventType.GOLDEN_HOUR,
|
||||
EventType.DOUBLE_RISK,
|
||||
EventType.JACKPOT,
|
||||
EventType.REMATCH,
|
||||
]
|
||||
|
||||
|
||||
class EventScheduler:
|
||||
"""Background scheduler for automatic event management."""
|
||||
|
||||
def __init__(self):
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
async def start(self, session_factory) -> None:
|
||||
"""Start the scheduler background task."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop(session_factory))
|
||||
print("[EventScheduler] Started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the scheduler."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
print("[EventScheduler] Stopped")
|
||||
|
||||
async def _run_loop(self, session_factory) -> None:
|
||||
"""Main scheduler loop."""
|
||||
while self._running:
|
||||
try:
|
||||
async with session_factory() as db:
|
||||
await self._process_events(db)
|
||||
except Exception as e:
|
||||
print(f"[EventScheduler] Error in loop: {e}")
|
||||
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
async def _process_events(self, db: AsyncSession) -> None:
|
||||
"""Process events - cleanup expired and potentially start new ones."""
|
||||
# 1. Cleanup expired events
|
||||
await self._cleanup_expired_events(db)
|
||||
|
||||
# 2. Maybe start new events for eligible marathons
|
||||
await self._maybe_start_events(db)
|
||||
|
||||
async def _cleanup_expired_events(self, db: AsyncSession) -> None:
|
||||
"""End any events that have expired."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Event).where(
|
||||
Event.is_active == True,
|
||||
Event.end_time < now,
|
||||
)
|
||||
)
|
||||
expired_events = result.scalars().all()
|
||||
|
||||
for event in expired_events:
|
||||
event.is_active = False
|
||||
print(f"[EventScheduler] Ended expired event {event.id} ({event.type})")
|
||||
|
||||
if expired_events:
|
||||
await db.commit()
|
||||
|
||||
async def _maybe_start_events(self, db: AsyncSession) -> None:
|
||||
"""Potentially start new events for eligible marathons."""
|
||||
# Get active marathons with auto_events enabled
|
||||
result = await db.execute(
|
||||
select(Marathon).where(
|
||||
Marathon.status == MarathonStatus.ACTIVE.value,
|
||||
Marathon.auto_events_enabled == True,
|
||||
)
|
||||
)
|
||||
marathons = result.scalars().all()
|
||||
|
||||
event_service = EventService()
|
||||
|
||||
for marathon in marathons:
|
||||
# Skip if random chance doesn't hit
|
||||
if random.random() > EVENT_PROBABILITY:
|
||||
continue
|
||||
|
||||
# Check if there's already an active event
|
||||
active_event = await event_service.get_active_event(db, marathon.id)
|
||||
if active_event:
|
||||
continue
|
||||
|
||||
# Check if enough time has passed since last event
|
||||
result = await db.execute(
|
||||
select(Event)
|
||||
.where(Event.marathon_id == marathon.id)
|
||||
.order_by(Event.end_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last_event = result.scalar_one_or_none()
|
||||
|
||||
if last_event:
|
||||
time_since_last = datetime.utcnow() - last_event.end_time
|
||||
if time_since_last < timedelta(hours=MIN_EVENT_GAP_HOURS):
|
||||
continue
|
||||
|
||||
# Start a random event
|
||||
event_type = random.choice(AUTO_EVENT_TYPES)
|
||||
|
||||
try:
|
||||
event = await event_service.start_event(
|
||||
db=db,
|
||||
marathon_id=marathon.id,
|
||||
event_type=event_type.value,
|
||||
created_by_id=None, # null = auto-started
|
||||
)
|
||||
print(
|
||||
f"[EventScheduler] Auto-started {event_type.value} for marathon {marathon.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[EventScheduler] Failed to start event for marathon {marathon.id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
event_scheduler = EventScheduler()
|
||||
227
backend/app/services/events.py
Normal file
227
backend/app/services/events.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select
|
||||
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.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
|
||||
|
||||
class EventService:
|
||||
"""Service for managing marathon events"""
|
||||
|
||||
async def get_active_event(self, db: AsyncSession, marathon_id: int) -> Event | None:
|
||||
"""Get currently active event for marathon"""
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(Event)
|
||||
.options(selectinload(Event.created_by))
|
||||
.where(
|
||||
Event.marathon_id == marathon_id,
|
||||
Event.is_active == True,
|
||||
Event.start_time <= now,
|
||||
)
|
||||
.order_by(Event.start_time.desc())
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
# Check if event has expired
|
||||
if event and event.end_time and event.end_time < now:
|
||||
await self.end_event(db, event.id)
|
||||
return None
|
||||
|
||||
return event
|
||||
|
||||
async def can_start_event(self, db: AsyncSession, marathon_id: int) -> bool:
|
||||
"""Check if we can start a new event (no active event exists)"""
|
||||
active = await self.get_active_event(db, marathon_id)
|
||||
return active is None
|
||||
|
||||
async def start_event(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
marathon_id: int,
|
||||
event_type: str,
|
||||
created_by_id: int | None = None,
|
||||
duration_minutes: int | None = None,
|
||||
challenge_id: int | None = None,
|
||||
) -> Event:
|
||||
"""Start a new event"""
|
||||
# Check no active event
|
||||
if not await self.can_start_event(db, marathon_id):
|
||||
raise ValueError("An event is already active")
|
||||
|
||||
# Get default duration if not provided
|
||||
event_info = EVENT_INFO.get(EventType(event_type), {})
|
||||
if duration_minutes is None:
|
||||
duration_minutes = event_info.get("default_duration")
|
||||
|
||||
now = datetime.utcnow()
|
||||
end_time = now + timedelta(minutes=duration_minutes) if duration_minutes else None
|
||||
|
||||
# Build event data
|
||||
data = {}
|
||||
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
|
||||
data["challenge_id"] = challenge_id
|
||||
data["completions"] = [] # Track who completed and when
|
||||
|
||||
event = Event(
|
||||
marathon_id=marathon_id,
|
||||
type=event_type,
|
||||
start_time=now,
|
||||
end_time=end_time,
|
||||
is_active=True,
|
||||
created_by_id=created_by_id,
|
||||
data=data if data else None,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
# Load created_by relationship
|
||||
if created_by_id:
|
||||
await db.refresh(event, ["created_by"])
|
||||
|
||||
return event
|
||||
|
||||
async def end_event(self, db: AsyncSession, event_id: int) -> None:
|
||||
"""End an event"""
|
||||
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()
|
||||
await db.commit()
|
||||
|
||||
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
|
||||
"""Consume jackpot event after one spin"""
|
||||
await self.end_event(db, event_id)
|
||||
|
||||
def get_event_effects(self, event: Event | None) -> EventEffects:
|
||||
"""Get effects of an event"""
|
||||
if not event:
|
||||
return EventEffects(description="Нет активного события")
|
||||
|
||||
event_info = EVENT_INFO.get(EventType(event.type), {})
|
||||
|
||||
return EventEffects(
|
||||
points_multiplier=event_info.get("points_multiplier", 1.0),
|
||||
drop_free=event_info.get("drop_free", False),
|
||||
special_action=event_info.get("special_action"),
|
||||
description=event_info.get("description", ""),
|
||||
)
|
||||
|
||||
async def get_random_hard_challenge(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
marathon_id: int
|
||||
) -> Challenge | None:
|
||||
"""Get a random hard challenge for jackpot event"""
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Challenge.game)
|
||||
.where(
|
||||
Challenge.game.has(marathon_id=marathon_id),
|
||||
Challenge.difficulty == Difficulty.HARD.value,
|
||||
)
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
if not challenges:
|
||||
# Fallback to any challenge
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Challenge.game)
|
||||
.where(Challenge.game.has(marathon_id=marathon_id))
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
if challenges:
|
||||
import random
|
||||
return random.choice(challenges)
|
||||
return None
|
||||
|
||||
async def record_common_enemy_completion(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event: Event,
|
||||
participant_id: int,
|
||||
user_id: int,
|
||||
) -> tuple[int, bool, list[dict] | None]:
|
||||
"""
|
||||
Record completion for common enemy event.
|
||||
Returns: (bonus_points, event_closed, winners_list)
|
||||
- bonus_points: bonus for this completion (top 3 get bonuses)
|
||||
- event_closed: True if event was auto-closed (3 completions reached)
|
||||
- winners_list: list of winners if event closed, None otherwise
|
||||
"""
|
||||
if event.type != EventType.COMMON_ENEMY.value:
|
||||
return 0, False, None
|
||||
|
||||
data = event.data or {}
|
||||
completions = data.get("completions", [])
|
||||
|
||||
# Check if already completed
|
||||
if any(c["participant_id"] == participant_id for c in completions):
|
||||
return 0, False, None
|
||||
|
||||
# Add completion
|
||||
rank = len(completions) + 1
|
||||
completions.append({
|
||||
"participant_id": participant_id,
|
||||
"user_id": user_id,
|
||||
"completed_at": datetime.utcnow().isoformat(),
|
||||
"rank": rank,
|
||||
})
|
||||
|
||||
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
|
||||
event.data = {**data, "completions": completions}
|
||||
flag_modified(event, "data")
|
||||
|
||||
bonus = COMMON_ENEMY_BONUSES.get(rank, 0)
|
||||
|
||||
# Auto-close event when 3 players completed
|
||||
event_closed = False
|
||||
winners_list = None
|
||||
if rank >= 3:
|
||||
event.is_active = False
|
||||
event.end_time = datetime.utcnow()
|
||||
event_closed = True
|
||||
winners_list = completions[:3] # Top 3
|
||||
|
||||
await db.commit()
|
||||
|
||||
return bonus, event_closed, winners_list
|
||||
|
||||
async def get_common_enemy_challenge(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event: Event
|
||||
) -> Challenge | None:
|
||||
"""Get the challenge for common enemy event"""
|
||||
if event.type != EventType.COMMON_ENEMY.value:
|
||||
return None
|
||||
|
||||
data = event.data or {}
|
||||
challenge_id = data.get("challenge_id")
|
||||
if not challenge_id:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.options(selectinload(Challenge.game))
|
||||
.where(Challenge.id == challenge_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:
|
||||
return None
|
||||
|
||||
remaining = (event.end_time - datetime.utcnow()).total_seconds()
|
||||
return max(0, int(remaining))
|
||||
|
||||
|
||||
event_service = EventService()
|
||||
@@ -1,3 +1,6 @@
|
||||
from app.models import Event, EventType
|
||||
|
||||
|
||||
class PointsService:
|
||||
"""Service for calculating points and penalties"""
|
||||
|
||||
@@ -17,39 +20,77 @@ class PointsService:
|
||||
}
|
||||
MAX_DROP_PENALTY = 50
|
||||
|
||||
# Event point multipliers
|
||||
EVENT_MULTIPLIERS = {
|
||||
EventType.GOLDEN_HOUR.value: 1.5,
|
||||
EventType.DOUBLE_RISK.value: 0.5,
|
||||
EventType.JACKPOT.value: 3.0,
|
||||
EventType.REMATCH.value: 0.5,
|
||||
}
|
||||
|
||||
def calculate_completion_points(
|
||||
self,
|
||||
base_points: int,
|
||||
current_streak: int
|
||||
) -> tuple[int, int]:
|
||||
current_streak: int,
|
||||
event: Event | None = None,
|
||||
) -> tuple[int, int, int]:
|
||||
"""
|
||||
Calculate points earned for completing a challenge.
|
||||
|
||||
Args:
|
||||
base_points: Base points for the challenge
|
||||
current_streak: Current streak before this completion
|
||||
event: Active event (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (total_points, streak_bonus)
|
||||
Tuple of (total_points, streak_bonus, event_bonus)
|
||||
"""
|
||||
multiplier = self.STREAK_MULTIPLIERS.get(
|
||||
# Apply event multiplier first
|
||||
event_multiplier = 1.0
|
||||
if event:
|
||||
event_multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
|
||||
|
||||
adjusted_base = int(base_points * event_multiplier)
|
||||
event_bonus = adjusted_base - base_points
|
||||
|
||||
# Then apply streak bonus
|
||||
streak_multiplier = self.STREAK_MULTIPLIERS.get(
|
||||
current_streak,
|
||||
self.MAX_STREAK_MULTIPLIER
|
||||
)
|
||||
bonus = int(base_points * multiplier)
|
||||
return base_points + bonus, bonus
|
||||
streak_bonus = int(adjusted_base * streak_multiplier)
|
||||
|
||||
def calculate_drop_penalty(self, consecutive_drops: int) -> int:
|
||||
total_points = adjusted_base + streak_bonus
|
||||
return total_points, streak_bonus, event_bonus
|
||||
|
||||
def calculate_drop_penalty(
|
||||
self,
|
||||
consecutive_drops: int,
|
||||
event: Event | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Calculate penalty for dropping a challenge.
|
||||
|
||||
Args:
|
||||
consecutive_drops: Number of drops since last completion
|
||||
event: Active event (optional)
|
||||
|
||||
Returns:
|
||||
Penalty points to subtract
|
||||
"""
|
||||
# Double risk event = free drops
|
||||
if event and event.type == EventType.DOUBLE_RISK.value:
|
||||
return 0
|
||||
|
||||
return self.DROP_PENALTIES.get(
|
||||
consecutive_drops,
|
||||
self.MAX_DROP_PENALTY
|
||||
)
|
||||
|
||||
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
|
||||
"""Apply event multiplier to points"""
|
||||
if not event:
|
||||
return base_points
|
||||
|
||||
multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
|
||||
return int(base_points * multiplier)
|
||||
|
||||
@@ -3,8 +3,8 @@ FROM node:20-alpine as build
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --network-timeout 300000
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
4253
frontend/package-lock.json
generated
Normal file
4253
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
frontend/src/api/challenges.ts
Normal file
9
frontend/src/api/challenges.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import client from './client'
|
||||
import type { Challenge } from '@/types'
|
||||
|
||||
export const challengesApi = {
|
||||
list: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/challenges`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
67
frontend/src/api/events.ts
Normal file
67
frontend/src/api/events.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import client from './client'
|
||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
|
||||
|
||||
export const eventsApi = {
|
||||
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
||||
const response = await client.get<ActiveEvent>(`/marathons/${marathonId}/event`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
list: async (marathonId: number): Promise<MarathonEvent[]> => {
|
||||
const response = await client.get<MarathonEvent[]>(`/marathons/${marathonId}/events`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
start: async (marathonId: number, data: EventCreate): Promise<MarathonEvent> => {
|
||||
const response = await client.post<MarathonEvent>(`/marathons/${marathonId}/events`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
stop: async (marathonId: number): Promise<void> => {
|
||||
await client.delete(`/marathons/${marathonId}/event`)
|
||||
},
|
||||
|
||||
// Swap requests (two-sided confirmation)
|
||||
createSwapRequest: async (marathonId: number, targetParticipantId: number): Promise<SwapRequestItem> => {
|
||||
const response = await client.post<SwapRequestItem>(`/marathons/${marathonId}/swap-requests`, {
|
||||
target_participant_id: targetParticipantId,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSwapRequests: async (marathonId: number): Promise<MySwapRequests> => {
|
||||
const response = await client.get<MySwapRequests>(`/marathons/${marathonId}/swap-requests`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
acceptSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
|
||||
await client.post(`/marathons/${marathonId}/swap-requests/${requestId}/accept`)
|
||||
},
|
||||
|
||||
declineSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
|
||||
await client.post(`/marathons/${marathonId}/swap-requests/${requestId}/decline`)
|
||||
},
|
||||
|
||||
cancelSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
|
||||
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
|
||||
},
|
||||
|
||||
rematch: async (marathonId: number, assignmentId: number): Promise<void> => {
|
||||
await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`)
|
||||
},
|
||||
|
||||
getDroppedAssignments: async (marathonId: number): Promise<DroppedAssignment[]> => {
|
||||
const response = await client.get<DroppedAssignment[]>(`/marathons/${marathonId}/dropped-assignments`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSwapCandidates: async (marathonId: number): Promise<SwapCandidate[]> => {
|
||||
const response = await client.get<SwapCandidate[]>(`/marathons/${marathonId}/swap-candidates`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getCommonEnemyLeaderboard: async (marathonId: number): Promise<CommonEnemyLeaderboardEntry[]> => {
|
||||
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export { gamesApi } from './games'
|
||||
export { wheelApi } from './wheel'
|
||||
export { feedApi } from './feed'
|
||||
export { adminApi } from './admin'
|
||||
export { eventsApi } from './events'
|
||||
export { challengesApi } from './challenges'
|
||||
|
||||
110
frontend/src/components/EventBanner.tsx
Normal file
110
frontend/src/components/EventBanner.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react'
|
||||
import type { ActiveEvent, EventType } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
|
||||
interface EventBannerProps {
|
||||
activeEvent: ActiveEvent
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-5 h-5" />,
|
||||
common_enemy: <Users className="w-5 h-5" />,
|
||||
double_risk: <Shield className="w-5 h-5" />,
|
||||
jackpot: <Gift className="w-5 h-5" />,
|
||||
swap: <ArrowLeftRight className="w-5 h-5" />,
|
||||
rematch: <RotateCcw className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<EventType, string> = {
|
||||
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400',
|
||||
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400',
|
||||
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',
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
|
||||
const [timeRemaining, setTimeRemaining] = useState(activeEvent.time_remaining_seconds)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeRemaining(activeEvent.time_remaining_seconds)
|
||||
}, [activeEvent.time_remaining_seconds])
|
||||
|
||||
useEffect(() => {
|
||||
if (timeRemaining === null || timeRemaining <= 0) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev === null || prev <= 0) {
|
||||
clearInterval(timer)
|
||||
onRefresh?.()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [timeRemaining, onRefresh])
|
||||
|
||||
if (!activeEvent.event) {
|
||||
return null
|
||||
}
|
||||
|
||||
const event = activeEvent.event
|
||||
const info = EVENT_INFO[event.type]
|
||||
const icon = EVENT_ICONS[event.type]
|
||||
const colorClass = EVENT_COLORS[event.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl border p-4
|
||||
bg-gradient-to-r ${colorClass}
|
||||
animate-pulse-slow
|
||||
`}
|
||||
>
|
||||
{/* Animated background effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
|
||||
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-white/10">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{info.name}</h3>
|
||||
<p className="text-sm opacity-80">{info.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeRemaining !== null && timeRemaining > 0 && (
|
||||
<div className="flex items-center gap-2 text-lg font-mono font-bold">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeEvent.effects.points_multiplier !== 1.0 && (
|
||||
<div className="px-3 py-1 rounded-full bg-white/10 font-bold">
|
||||
x{activeEvent.effects.points_multiplier}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
frontend/src/components/EventControl.tsx
Normal file
161
frontend/src/components/EventControl.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { eventsApi } from '@/api'
|
||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
|
||||
interface EventControlProps {
|
||||
marathonId: number
|
||||
activeEvent: ActiveEvent
|
||||
challenges?: Challenge[]
|
||||
onEventChange: () => void
|
||||
}
|
||||
|
||||
const EVENT_TYPES: EventType[] = [
|
||||
'golden_hour',
|
||||
'double_risk',
|
||||
'jackpot',
|
||||
'swap',
|
||||
'rematch',
|
||||
'common_enemy',
|
||||
]
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-4 h-4" />,
|
||||
common_enemy: <Users className="w-4 h-4" />,
|
||||
double_risk: <Shield className="w-4 h-4" />,
|
||||
jackpot: <Gift className="w-4 h-4" />,
|
||||
swap: <ArrowLeftRight className="w-4 h-4" />,
|
||||
rematch: <RotateCcw className="w-4 h-4" />,
|
||||
}
|
||||
|
||||
export function EventControl({
|
||||
marathonId,
|
||||
activeEvent,
|
||||
challenges,
|
||||
onEventChange,
|
||||
}: EventControlProps) {
|
||||
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
||||
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
|
||||
const handleStart = async () => {
|
||||
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
||||
alert('Выберите челлендж для события "Общий враг"')
|
||||
return
|
||||
}
|
||||
|
||||
setIsStarting(true)
|
||||
try {
|
||||
await eventsApi.start(marathonId, {
|
||||
type: selectedType,
|
||||
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
|
||||
})
|
||||
onEventChange()
|
||||
} catch (error) {
|
||||
console.error('Failed to start event:', error)
|
||||
alert('Не удалось запустить событие')
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!confirm('Остановить событие досрочно?')) return
|
||||
|
||||
setIsStopping(true)
|
||||
try {
|
||||
await eventsApi.stop(marathonId)
|
||||
onEventChange()
|
||||
} catch (error) {
|
||||
console.error('Failed to stop event:', error)
|
||||
} finally {
|
||||
setIsStopping(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEvent.event) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{EVENT_ICONS[activeEvent.event.type]}
|
||||
<span className="font-medium">
|
||||
Активно: {EVENT_INFO[activeEvent.event.type].name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
isLoading={isStopping}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Остановить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl space-y-4">
|
||||
<h3 className="font-bold text-white">Запустить событие</h3>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`
|
||||
p-3 rounded-lg border-2 transition-all text-left
|
||||
${selectedType === type
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{EVENT_ICONS[type]}
|
||||
<span className="font-medium text-sm">{EVENT_INFO[type].name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{EVENT_INFO[type].description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Выберите челлендж для всех
|
||||
</label>
|
||||
<select
|
||||
value={selectedChallengeId || ''}
|
||||
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
||||
>
|
||||
<option value="">— Выберите челлендж —</option>
|
||||
{challenges.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.game.title}: {c.title} ({c.points} очков)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
isLoading={isStarting}
|
||||
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
|
||||
className="w-full"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Запустить {EVENT_INFO[selectedType].name}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
209
frontend/src/components/SpinWheel.tsx
Normal file
209
frontend/src/components/SpinWheel.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type { Game } from '@/types'
|
||||
|
||||
interface SpinWheelProps {
|
||||
games: Game[]
|
||||
onSpin: () => Promise<Game | null>
|
||||
onSpinComplete: (game: Game) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 100
|
||||
const VISIBLE_ITEMS = 5
|
||||
const SPIN_DURATION = 4000
|
||||
const EXTRA_ROTATIONS = 3
|
||||
|
||||
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
|
||||
const [isSpinning, setIsSpinning] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
|
||||
// Create extended list for seamless looping
|
||||
const extendedGames = [...games, ...games, ...games, ...games, ...games]
|
||||
|
||||
const handleSpin = useCallback(async () => {
|
||||
if (isSpinning || disabled || games.length === 0) return
|
||||
|
||||
setIsSpinning(true)
|
||||
|
||||
// Get result from API first
|
||||
const resultGame = await onSpin()
|
||||
if (!resultGame) {
|
||||
setIsSpinning(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Find target index
|
||||
const targetIndex = games.findIndex(g => g.id === resultGame.id)
|
||||
if (targetIndex === -1) {
|
||||
setIsSpinning(false)
|
||||
onSpinComplete(resultGame)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate animation
|
||||
const totalItems = games.length
|
||||
const fullRotations = EXTRA_ROTATIONS * totalItems
|
||||
const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT
|
||||
|
||||
// Animate
|
||||
const startTime = Date.now()
|
||||
const startOffset = offset % (totalItems * ITEM_HEIGHT)
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / SPIN_DURATION, 1)
|
||||
|
||||
// Easing function - starts fast, slows down at end
|
||||
const easeOut = 1 - Math.pow(1 - progress, 4)
|
||||
|
||||
const currentOffset = startOffset + (finalPosition - startOffset) * easeOut
|
||||
setOffset(currentOffset)
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
setIsSpinning(false)
|
||||
onSpinComplete(resultGame)
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
}, [isSpinning, disabled, games, offset, onSpin, onSpinComplete])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (games.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Нет доступных игр для прокрутки
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
|
||||
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
|
||||
|
||||
// Calculate opacity based on distance from center
|
||||
const getItemOpacity = (itemIndex: number) => {
|
||||
const itemPosition = itemIndex * ITEM_HEIGHT - offset
|
||||
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
|
||||
const distanceFromCenter = Math.abs(itemPosition - centerPosition)
|
||||
const maxDistance = containerHeight / 2
|
||||
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8)
|
||||
return opacity
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Wheel container */}
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Selection indicator */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] border-2 border-primary-500 rounded-lg bg-primary-500/10 z-20 pointer-events-none">
|
||||
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-t-transparent border-b-transparent border-r-primary-500" />
|
||||
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-l-8 border-t-transparent border-b-transparent border-l-primary-500" />
|
||||
</div>
|
||||
|
||||
{/* Items container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{ height: containerHeight }}
|
||||
>
|
||||
<div
|
||||
className="absolute w-full transition-none"
|
||||
style={{
|
||||
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`,
|
||||
}}
|
||||
>
|
||||
{extendedGames.map((game, index) => {
|
||||
const realIndex = index % games.length
|
||||
const isSelected = !isSpinning && realIndex === currentIndex
|
||||
const opacity = getItemOpacity(index)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${game.id}-${index}`}
|
||||
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${
|
||||
isSelected ? 'scale-105' : ''
|
||||
}`}
|
||||
style={{ height: ITEM_HEIGHT, opacity }}
|
||||
>
|
||||
{/* Game cover */}
|
||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0">
|
||||
{game.cover_url ? (
|
||||
<img
|
||||
src={game.cover_url}
|
||||
alt={game.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-2xl">
|
||||
🎮
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Game info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-white truncate text-lg">
|
||||
{game.title}
|
||||
</h3>
|
||||
{game.genre && (
|
||||
<p className="text-sm text-gray-400 truncate">{game.genre}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spin button */}
|
||||
<button
|
||||
onClick={handleSpin}
|
||||
disabled={isSpinning || disabled}
|
||||
className={`
|
||||
relative px-12 py-4 text-xl font-bold rounded-full
|
||||
transition-all duration-300 transform
|
||||
${isSpinning || disabled
|
||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:scale-105 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSpinning ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-6 h-6" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Крутится...
|
||||
</span>
|
||||
) : (
|
||||
'КРУТИТЬ!'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { Marathon } from '@/types'
|
||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft } from 'lucide-react'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { EventControl } from '@/components/EventControl'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export function MarathonPage() {
|
||||
@@ -12,10 +14,13 @@ export function MarathonPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const [showEventControl, setShowEventControl] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathon()
|
||||
@@ -26,6 +31,22 @@ export function MarathonPage() {
|
||||
try {
|
||||
const data = await marathonsApi.get(parseInt(id))
|
||||
setMarathon(data)
|
||||
|
||||
// Load event data if marathon is active
|
||||
if (data.status === 'active' && data.my_participation) {
|
||||
const eventData = await eventsApi.getActive(parseInt(id))
|
||||
setActiveEvent(eventData)
|
||||
|
||||
// Load challenges for event control if organizer
|
||||
if (data.my_participation.role === 'organizer') {
|
||||
try {
|
||||
const challengesData = await challengesApi.list(parseInt(id))
|
||||
setChallenges(challengesData)
|
||||
} catch {
|
||||
// Ignore if no challenges
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load marathon:', error)
|
||||
navigate('/marathons')
|
||||
@@ -34,6 +55,16 @@ export function MarathonPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshEvent = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const eventData = await eventsApi.getActive(parseInt(id))
|
||||
setActiveEvent(eventData)
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh event:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getInviteLink = () => {
|
||||
if (!marathon) return ''
|
||||
return `${window.location.origin}/invite/${marathon.invite_code}`
|
||||
@@ -234,6 +265,42 @@ export function MarathonPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Active event banner */}
|
||||
{marathon.status === 'active' && activeEvent?.event && (
|
||||
<div className="mb-8">
|
||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event control for organizers */}
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-500" />
|
||||
Управление событиями
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowEventControl(!showEventControl)}
|
||||
>
|
||||
{showEventControl ? 'Скрыть' : 'Показать'}
|
||||
</Button>
|
||||
</div>
|
||||
{showEventControl && activeEvent && (
|
||||
<EventControl
|
||||
marathonId={marathon.id}
|
||||
activeEvent={activeEvent}
|
||||
challenges={challenges}
|
||||
onEventChange={refreshEvent}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invite link */}
|
||||
{marathon.status !== 'finished' && (
|
||||
<Card className="mb-8">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { marathonsApi, wheelApi } from '@/api'
|
||||
import type { Marathon, Assignment, SpinResult } from '@/types'
|
||||
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 { Button, Card, CardContent } from '@/components/ui'
|
||||
import { Loader2, Upload, X } from 'lucide-react'
|
||||
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'
|
||||
|
||||
export function PlayPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -11,11 +13,10 @@ export function PlayPage() {
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
||||
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Spin state
|
||||
const [isSpinning, setIsSpinning] = useState(false)
|
||||
|
||||
// Complete state
|
||||
const [proofFile, setProofFile] = useState<File | null>(null)
|
||||
const [proofUrl, setProofUrl] = useState('')
|
||||
@@ -25,21 +26,113 @@ export function PlayPage() {
|
||||
// Drop state
|
||||
const [isDropping, setIsDropping] = useState(false)
|
||||
|
||||
// Rematch state
|
||||
const [droppedAssignments, setDroppedAssignments] = useState<DroppedAssignment[]>([])
|
||||
const [isRematchLoading, setIsRematchLoading] = useState(false)
|
||||
const [rematchingId, setRematchingId] = useState<number | null>(null)
|
||||
|
||||
// Swap state
|
||||
const [swapCandidates, setSwapCandidates] = useState<SwapCandidate[]>([])
|
||||
const [swapRequests, setSwapRequests] = useState<MySwapRequests>({ incoming: [], outgoing: [] })
|
||||
const [isSwapLoading, setIsSwapLoading] = useState(false)
|
||||
const [sendingRequestTo, setSendingRequestTo] = useState<number | null>(null)
|
||||
const [processingRequestId, setProcessingRequestId] = useState<number | null>(null)
|
||||
|
||||
// Common Enemy leaderboard state
|
||||
const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([])
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [id])
|
||||
|
||||
// Load dropped assignments when rematch event is active
|
||||
useEffect(() => {
|
||||
if (activeEvent?.event?.type === 'rematch' && !currentAssignment) {
|
||||
loadDroppedAssignments()
|
||||
}
|
||||
}, [activeEvent?.event?.type, currentAssignment])
|
||||
|
||||
// Load swap candidates and requests when swap event is active
|
||||
useEffect(() => {
|
||||
if (activeEvent?.event?.type === 'swap') {
|
||||
loadSwapRequests()
|
||||
if (currentAssignment) {
|
||||
loadSwapCandidates()
|
||||
}
|
||||
}
|
||||
}, [activeEvent?.event?.type, currentAssignment])
|
||||
|
||||
// Load common enemy leaderboard when common_enemy event is active
|
||||
useEffect(() => {
|
||||
if (activeEvent?.event?.type === 'common_enemy') {
|
||||
loadCommonEnemyLeaderboard()
|
||||
// Poll for updates every 10 seconds
|
||||
const interval = setInterval(loadCommonEnemyLeaderboard, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [activeEvent?.event?.type])
|
||||
|
||||
const loadDroppedAssignments = async () => {
|
||||
if (!id) return
|
||||
setIsRematchLoading(true)
|
||||
try {
|
||||
const dropped = await eventsApi.getDroppedAssignments(parseInt(id))
|
||||
setDroppedAssignments(dropped)
|
||||
} catch (error) {
|
||||
console.error('Failed to load dropped assignments:', error)
|
||||
} finally {
|
||||
setIsRematchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSwapCandidates = async () => {
|
||||
if (!id) return
|
||||
setIsSwapLoading(true)
|
||||
try {
|
||||
const candidates = await eventsApi.getSwapCandidates(parseInt(id))
|
||||
setSwapCandidates(candidates)
|
||||
} catch (error) {
|
||||
console.error('Failed to load swap candidates:', error)
|
||||
} finally {
|
||||
setIsSwapLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSwapRequests = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const requests = await eventsApi.getSwapRequests(parseInt(id))
|
||||
setSwapRequests(requests)
|
||||
} catch (error) {
|
||||
console.error('Failed to load swap requests:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCommonEnemyLeaderboard = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const leaderboard = await eventsApi.getCommonEnemyLeaderboard(parseInt(id))
|
||||
setCommonEnemyLeaderboard(leaderboard)
|
||||
} catch (error) {
|
||||
console.error('Failed to load common enemy leaderboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const [marathonData, assignment] = await Promise.all([
|
||||
const [marathonData, assignment, gamesData, eventData] = await Promise.all([
|
||||
marathonsApi.get(parseInt(id)),
|
||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||
gamesApi.list(parseInt(id), 'approved'),
|
||||
eventsApi.getActive(parseInt(id)),
|
||||
])
|
||||
setMarathon(marathonData)
|
||||
setCurrentAssignment(assignment)
|
||||
setGames(gamesData)
|
||||
setActiveEvent(eventData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
@@ -47,24 +140,37 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpin = async () => {
|
||||
const refreshEvent = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const eventData = await eventsApi.getActive(parseInt(id))
|
||||
setActiveEvent(eventData)
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh event:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpin = async (): Promise<Game | null> => {
|
||||
if (!id) return null
|
||||
|
||||
setIsSpinning(true)
|
||||
setSpinResult(null)
|
||||
try {
|
||||
const result = await wheelApi.spin(parseInt(id))
|
||||
setSpinResult(result)
|
||||
// Reload to get assignment
|
||||
await loadData()
|
||||
return result.game
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось крутить')
|
||||
} finally {
|
||||
setIsSpinning(false)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpinComplete = async () => {
|
||||
// Small delay then reload data to show the assignment
|
||||
setTimeout(async () => {
|
||||
await loadData()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!currentAssignment) return
|
||||
if (!proofFile && !proofUrl) {
|
||||
@@ -118,6 +224,92 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRematch = async (assignmentId: number) => {
|
||||
if (!id) return
|
||||
|
||||
if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return
|
||||
|
||||
setRematchingId(assignmentId)
|
||||
try {
|
||||
await eventsApi.rematch(parseInt(id), assignmentId)
|
||||
alert('Реванш начат! Выполните задание за 50% очков.')
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось начать реванш')
|
||||
} finally {
|
||||
setRematchingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
|
||||
if (!id) return
|
||||
|
||||
if (!confirm(`Отправить запрос на обмен с ${participantName}?\n\nВы предлагаете обменяться на: "${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`)) return
|
||||
|
||||
setSendingRequestTo(participantId)
|
||||
try {
|
||||
await eventsApi.createSwapRequest(parseInt(id), participantId)
|
||||
alert('Запрос на обмен отправлен! Ожидайте подтверждения.')
|
||||
await loadSwapRequests()
|
||||
await loadSwapCandidates()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отправить запрос')
|
||||
} finally {
|
||||
setSendingRequestTo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcceptSwapRequest = async (requestId: number) => {
|
||||
if (!id) return
|
||||
|
||||
if (!confirm('Принять обмен? Задания будут обменяны сразу после подтверждения.')) return
|
||||
|
||||
setProcessingRequestId(requestId)
|
||||
try {
|
||||
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
|
||||
alert('Обмен выполнен!')
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось выполнить обмен')
|
||||
} finally {
|
||||
setProcessingRequestId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeclineSwapRequest = async (requestId: number) => {
|
||||
if (!id) return
|
||||
|
||||
setProcessingRequestId(requestId)
|
||||
try {
|
||||
await eventsApi.declineSwapRequest(parseInt(id), requestId)
|
||||
await loadSwapRequests()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отклонить запрос')
|
||||
} finally {
|
||||
setProcessingRequestId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelSwapRequest = async (requestId: number) => {
|
||||
if (!id) return
|
||||
|
||||
setProcessingRequestId(requestId)
|
||||
try {
|
||||
await eventsApi.cancelSwapRequest(parseInt(id), requestId)
|
||||
await loadSwapRequests()
|
||||
await loadSwapCandidates()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отменить запрос')
|
||||
} finally {
|
||||
setProcessingRequestId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -134,8 +326,14 @@ export function PlayPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
К марафону
|
||||
</Link>
|
||||
|
||||
{/* Header stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="text-center py-3">
|
||||
<div className="text-xl font-bold text-primary-500">
|
||||
@@ -162,23 +360,144 @@ export function PlayPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* No active assignment - show spin */}
|
||||
{!currentAssignment && (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Крутите колесо!</h2>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Получите случайную игру и задание для выполнения
|
||||
</p>
|
||||
<Button size="lg" onClick={handleSpin} isLoading={isSpinning}>
|
||||
{isSpinning ? 'Крутим...' : 'КРУТИТЬ'}
|
||||
</Button>
|
||||
{/* Active event banner */}
|
||||
{activeEvent?.event && (
|
||||
<div className="mb-6">
|
||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Common Enemy Leaderboard */}
|
||||
{activeEvent?.event?.type === 'common_enemy' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* No active assignment - show spin wheel */}
|
||||
{!currentAssignment && (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="py-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2 text-center">Крутите колесо!</h2>
|
||||
<p className="text-gray-400 mb-6 text-center">
|
||||
Получите случайную игру и задание для выполнения
|
||||
</p>
|
||||
<SpinWheel
|
||||
games={games}
|
||||
onSpin={handleSpin}
|
||||
onSpinComplete={handleSpinComplete}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rematch section - show during rematch event */}
|
||||
{activeEvent?.event?.type === 'rematch' && droppedAssignments.length > 0 && (
|
||||
<Card className="mt-6">
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<RotateCcw className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-bold text-white">Реванш</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Во время события "Реванш" вы можете повторить пропущенные задания за 50% очков
|
||||
</p>
|
||||
|
||||
{isRematchLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{droppedAssignments.map((dropped) => (
|
||||
<div
|
||||
key={dropped.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">
|
||||
{dropped.challenge.title}
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{dropped.challenge.game.title} • {dropped.challenge.points * 0.5} очков
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRematch(dropped.id)}
|
||||
isLoading={rematchingId === dropped.id}
|
||||
disabled={rematchingId !== null}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Реванш
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Active assignment */}
|
||||
{currentAssignment && (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center mb-6">
|
||||
@@ -309,6 +628,184 @@ export function PlayPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Swap section - show during swap event when user has active assignment */}
|
||||
{activeEvent?.event?.type === 'swap' && (
|
||||
<Card className="mt-6">
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ArrowLeftRight className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-lg font-bold text-white">Обмен заданиями</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Обмен требует подтверждения с обеих сторон
|
||||
</p>
|
||||
|
||||
{/* Incoming swap requests */}
|
||||
{swapRequests.incoming.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-yellow-400 mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Входящие запросы ({swapRequests.incoming.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{swapRequests.incoming.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">
|
||||
{request.from_user.nickname} предлагает обмен
|
||||
</p>
|
||||
<p className="text-yellow-400 text-sm mt-1">
|
||||
Вы получите: <span className="font-medium">{request.from_challenge.title}</span>
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs">
|
||||
{request.from_challenge.game_title} • {request.from_challenge.points} очков
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Взамен на: <span className="font-medium">{request.to_challenge.title}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptSwapRequest(request.id)}
|
||||
isLoading={processingRequestId === request.id}
|
||||
disabled={processingRequestId !== null}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Принять
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => handleDeclineSwapRequest(request.id)}
|
||||
isLoading={processingRequestId === request.id}
|
||||
disabled={processingRequestId !== null}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outgoing swap requests */}
|
||||
{swapRequests.outgoing.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-blue-400 mb-3 flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
Отправленные запросы ({swapRequests.outgoing.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{swapRequests.outgoing.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">
|
||||
Запрос к {request.to_user.nickname}
|
||||
</p>
|
||||
<p className="text-blue-400 text-sm mt-1">
|
||||
Вы получите: <span className="font-medium">{request.to_challenge.title}</span>
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs">
|
||||
{request.to_challenge.game_title} • {request.to_challenge.points} очков
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
Ожидание подтверждения...
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleCancelSwapRequest(request.id)}
|
||||
isLoading={processingRequestId === request.id}
|
||||
disabled={processingRequestId !== null}
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Отменить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Swap candidates */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-3">
|
||||
Доступные для обмена
|
||||
</h4>
|
||||
{isSwapLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : swapCandidates.filter(c =>
|
||||
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
||||
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
|
||||
).length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Нет участников для обмена
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{swapCandidates
|
||||
.filter(c =>
|
||||
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
||||
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
|
||||
)
|
||||
.map((candidate) => (
|
||||
<div
|
||||
key={candidate.participant_id}
|
||||
className="p-3 bg-gray-900 rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">
|
||||
{candidate.user.nickname}
|
||||
</p>
|
||||
<p className="text-blue-400 text-sm font-medium truncate">
|
||||
{candidate.challenge_title}
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
{candidate.game_title} • {candidate.challenge_points} очков • {candidate.challenge_difficulty}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleSendSwapRequest(
|
||||
candidate.participant_id,
|
||||
candidate.user.nickname,
|
||||
candidate.challenge_title
|
||||
)}
|
||||
isLoading={sendingRequestTo === candidate.participant_id}
|
||||
disabled={sendingRequestTo !== null}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4 mr-1" />
|
||||
Предложить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface Marathon {
|
||||
invite_code: string
|
||||
is_public: boolean
|
||||
game_proposal_mode: GameProposalMode
|
||||
auto_events_enabled: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
participants_count: number
|
||||
@@ -192,6 +193,58 @@ export interface DropResult {
|
||||
new_drop_count: number
|
||||
}
|
||||
|
||||
export interface DroppedAssignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
dropped_at: string
|
||||
}
|
||||
|
||||
export interface SwapCandidate {
|
||||
participant_id: number
|
||||
user: User
|
||||
challenge_title: string
|
||||
challenge_description: string
|
||||
challenge_points: number
|
||||
challenge_difficulty: Difficulty
|
||||
game_title: string
|
||||
}
|
||||
|
||||
// Two-sided swap confirmation types
|
||||
export type SwapRequestStatus = 'pending' | 'accepted' | 'declined' | 'cancelled'
|
||||
|
||||
export interface SwapRequestChallengeInfo {
|
||||
title: string
|
||||
description: string
|
||||
points: number
|
||||
difficulty: string
|
||||
game_title: string
|
||||
}
|
||||
|
||||
export interface SwapRequestItem {
|
||||
id: number
|
||||
status: SwapRequestStatus
|
||||
from_user: User
|
||||
to_user: User
|
||||
from_challenge: SwapRequestChallengeInfo
|
||||
to_challenge: SwapRequestChallengeInfo
|
||||
created_at: string
|
||||
responded_at: string | null
|
||||
}
|
||||
|
||||
export interface MySwapRequests {
|
||||
incoming: SwapRequestItem[]
|
||||
outgoing: SwapRequestItem[]
|
||||
}
|
||||
|
||||
// Common Enemy leaderboard
|
||||
export interface CommonEnemyLeaderboardEntry {
|
||||
participant_id: number
|
||||
user: User
|
||||
completed_at: string | null
|
||||
rank: number | null
|
||||
bonus_points: number
|
||||
}
|
||||
|
||||
// Activity types
|
||||
export type ActivityType =
|
||||
| 'join'
|
||||
@@ -218,6 +271,78 @@ export interface FeedResponse {
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// Event types
|
||||
export type EventType =
|
||||
| 'golden_hour'
|
||||
| 'common_enemy'
|
||||
| 'double_risk'
|
||||
| 'jackpot'
|
||||
| 'swap'
|
||||
| 'rematch'
|
||||
|
||||
export interface MarathonEvent {
|
||||
id: number
|
||||
type: EventType
|
||||
start_time: string
|
||||
end_time: string | null
|
||||
is_active: boolean
|
||||
created_by: User | null
|
||||
data: Record<string, unknown> | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface EventEffects {
|
||||
points_multiplier: number
|
||||
drop_free: boolean
|
||||
special_action: string | null
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ActiveEvent {
|
||||
event: MarathonEvent | null
|
||||
effects: EventEffects
|
||||
time_remaining_seconds: number | null
|
||||
}
|
||||
|
||||
export interface EventCreate {
|
||||
type: EventType
|
||||
duration_minutes?: number
|
||||
challenge_id?: number
|
||||
}
|
||||
|
||||
export const EVENT_INFO: Record<EventType, { name: string; description: string; color: string }> = {
|
||||
golden_hour: {
|
||||
name: 'Золотой час',
|
||||
description: 'Все очки x1.5!',
|
||||
color: 'yellow',
|
||||
},
|
||||
common_enemy: {
|
||||
name: 'Общий враг',
|
||||
description: 'Все получают одинаковый челлендж. Первые 3 — бонус!',
|
||||
color: 'red',
|
||||
},
|
||||
double_risk: {
|
||||
name: 'Двойной риск',
|
||||
description: 'Дропы бесплатны, но очки x0.5',
|
||||
color: 'purple',
|
||||
},
|
||||
jackpot: {
|
||||
name: 'Джекпот',
|
||||
description: 'Следующий спин — сложный челлендж с x3 очками!',
|
||||
color: 'green',
|
||||
},
|
||||
swap: {
|
||||
name: 'Обмен',
|
||||
description: 'Можно поменяться заданием с другим участником',
|
||||
color: 'blue',
|
||||
},
|
||||
rematch: {
|
||||
name: 'Реванш',
|
||||
description: 'Можно переделать проваленный челлендж за 50% очков',
|
||||
color: 'orange',
|
||||
},
|
||||
}
|
||||
|
||||
// Admin types
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user