Add events
This commit is contained in:
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 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")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -12,3 +12,4 @@ router.include_router(challenges.router)
|
|||||||
router.include_router(wheel.router)
|
router.include_router(wheel.router)
|
||||||
router.include_router(feed.router)
|
router.include_router(feed.router)
|
||||||
router.include_router(admin.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)
|
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||||
async def create_challenge(
|
async def create_challenge(
|
||||||
game_id: int,
|
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,
|
invite_code=marathon.invite_code,
|
||||||
is_public=marathon.is_public,
|
is_public=marathon.is_public,
|
||||||
game_proposal_mode=marathon.game_proposal_mode,
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
|
auto_events_enabled=marathon.auto_events_enabled,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=1,
|
participants_count=1,
|
||||||
@@ -206,6 +207,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
|
|||||||
invite_code=marathon.invite_code,
|
invite_code=marathon.invite_code,
|
||||||
is_public=marathon.is_public,
|
is_public=marathon.is_public,
|
||||||
game_proposal_mode=marathon.game_proposal_mode,
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
|
auto_events_enabled=marathon.auto_events_enabled,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=participants_count,
|
participants_count=participants_count,
|
||||||
@@ -240,6 +242,8 @@ async def update_marathon(
|
|||||||
marathon.is_public = data.is_public
|
marathon.is_public = data.is_public
|
||||||
if data.game_proposal_mode is not None:
|
if data.game_proposal_mode is not None:
|
||||||
marathon.game_proposal_mode = data.game_proposal_mode
|
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()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ from app.api.deps import DbSession, CurrentUser
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType
|
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||||
|
EventType, Difficulty
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
||||||
)
|
)
|
||||||
from app.services.points import PointsService
|
from app.services.points import PointsService
|
||||||
|
from app.services.events import event_service
|
||||||
|
|
||||||
router = APIRouter(tags=["wheel"])
|
router = APIRouter(tags=["wheel"])
|
||||||
|
|
||||||
@@ -69,46 +71,91 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
if active:
|
if active:
|
||||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||||
|
|
||||||
# Get all games with challenges
|
# Check active event
|
||||||
result = await db.execute(
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
select(Game)
|
|
||||||
.options(selectinload(Game.challenges))
|
|
||||||
.where(Game.marathon_id == marathon_id)
|
|
||||||
)
|
|
||||||
games = [g for g in result.scalars().all() if g.challenges]
|
|
||||||
|
|
||||||
if not games:
|
game = None
|
||||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
challenge = None
|
||||||
|
|
||||||
# Random selection
|
# Handle special event cases
|
||||||
game = random.choice(games)
|
if active_event:
|
||||||
challenge = random.choice(game.challenges)
|
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)
|
||||||
|
|
||||||
# Create assignment
|
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))
|
||||||
|
.where(Game.marathon_id == marathon_id)
|
||||||
|
)
|
||||||
|
games = [g for g in result.scalars().all() if g.challenges]
|
||||||
|
|
||||||
|
if not games:
|
||||||
|
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||||||
|
|
||||||
|
game = random.choice(games)
|
||||||
|
challenge = random.choice(game.challenges)
|
||||||
|
|
||||||
|
# Create assignment (store event_type for jackpot multiplier on completion)
|
||||||
assignment = Assignment(
|
assignment = Assignment(
|
||||||
participant_id=participant.id,
|
participant_id=participant.id,
|
||||||
challenge_id=challenge.id,
|
challenge_id=challenge.id,
|
||||||
status=AssignmentStatus.ACTIVE.value,
|
status=AssignmentStatus.ACTIVE.value,
|
||||||
|
event_type=active_event.type if active_event else None,
|
||||||
)
|
)
|
||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
|
activity_data = {
|
||||||
|
"game": game.title,
|
||||||
|
"challenge": challenge.title,
|
||||||
|
}
|
||||||
|
if active_event:
|
||||||
|
activity_data["event_type"] = active_event.type
|
||||||
|
|
||||||
activity = Activity(
|
activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
type=ActivityType.SPIN.value,
|
type=ActivityType.SPIN.value,
|
||||||
data={
|
data=activity_data,
|
||||||
"game": game.title,
|
|
||||||
"challenge": challenge.title,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(assignment)
|
await db.refresh(assignment)
|
||||||
|
|
||||||
# Calculate drop penalty
|
# Calculate drop penalty (considers active event for double_risk)
|
||||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
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(
|
return SpinResult(
|
||||||
assignment_id=assignment.id,
|
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,
|
download_url=game.download_url,
|
||||||
genre=game.genre,
|
genre=game.genre,
|
||||||
added_by=None,
|
added_by=None,
|
||||||
challenges_count=len(game.challenges),
|
challenges_count=challenges_count,
|
||||||
created_at=game.created_at,
|
created_at=game.created_at,
|
||||||
),
|
),
|
||||||
challenge=ChallengeResponse(
|
challenge=ChallengeResponse(
|
||||||
@@ -246,9 +293,41 @@ async def complete_assignment(
|
|||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
challenge = assignment.challenge
|
challenge = assignment.challenge
|
||||||
|
|
||||||
total_points, streak_bonus = points_service.calculate_completion_points(
|
# Get marathon_id for activity and event check
|
||||||
challenge.points, participant.current_streak
|
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
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -261,25 +340,53 @@ async def complete_assignment(
|
|||||||
participant.current_streak += 1
|
participant.current_streak += 1
|
||||||
participant.drop_count = 0 # Reset drop counter on success
|
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
|
# Log activity
|
||||||
|
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(
|
activity = Activity(
|
||||||
marathon_id=full_challenge.game.marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
type=ActivityType.COMPLETE.value,
|
type=ActivityType.COMPLETE.value,
|
||||||
data={
|
data=activity_data,
|
||||||
"challenge": challenge.title,
|
|
||||||
"points": total_points,
|
|
||||||
"streak": participant.current_streak,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
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()
|
await db.commit()
|
||||||
|
|
||||||
return CompleteResult(
|
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")
|
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||||
|
|
||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
|
||||||
# Calculate penalty
|
# Check active event for free drops (double_risk)
|
||||||
penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
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
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
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
|
participant.drop_count += 1
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
|
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(
|
activity = Activity(
|
||||||
marathon_id=assignment.challenge.game.marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
type=ActivityType.DROP.value,
|
type=ActivityType.DROP.value,
|
||||||
data={
|
data=activity_data,
|
||||||
"challenge": assignment.challenge.title,
|
|
||||||
"penalty": penalty,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.config import settings
|
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.api.v1 import router as api_router
|
||||||
|
from app.services.event_scheduler import event_scheduler
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -22,9 +23,13 @@ async def lifespan(app: FastAPI):
|
|||||||
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
|
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
|
||||||
(upload_dir / "proofs").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
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
await event_scheduler.stop()
|
||||||
await engine.dispose()
|
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.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||||
from app.models.assignment import Assignment, AssignmentStatus
|
from app.models.assignment import Assignment, AssignmentStatus
|
||||||
from app.models.activity import Activity, ActivityType
|
from app.models.activity import Activity, ActivityType
|
||||||
|
from app.models.event import Event, EventType
|
||||||
|
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -24,4 +26,8 @@ __all__ = [
|
|||||||
"AssignmentStatus",
|
"AssignmentStatus",
|
||||||
"Activity",
|
"Activity",
|
||||||
"ActivityType",
|
"ActivityType",
|
||||||
|
"Event",
|
||||||
|
"EventType",
|
||||||
|
"SwapRequest",
|
||||||
|
"SwapRequestStatus",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class ActivityType(str, Enum):
|
|||||||
ADD_GAME = "add_game"
|
ADD_GAME = "add_game"
|
||||||
APPROVE_GAME = "approve_game"
|
APPROVE_GAME = "approve_game"
|
||||||
REJECT_GAME = "reject_game"
|
REJECT_GAME = "reject_game"
|
||||||
|
EVENT_START = "event_start"
|
||||||
|
EVENT_END = "event_end"
|
||||||
|
SWAP = "swap"
|
||||||
|
REMATCH = "rematch"
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Assignment(Base):
|
|||||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
|
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
|
||||||
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
||||||
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
||||||
|
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
||||||
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
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)
|
game_proposal_mode: Mapped[str] = mapped_column(String(20), default=GameProposalMode.ALL_PARTICIPANTS.value)
|
||||||
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
end_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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -53,3 +54,8 @@ class Marathon(Base):
|
|||||||
back_populates="marathon",
|
back_populates="marathon",
|
||||||
cascade="all, delete-orphan"
|
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,
|
ActivityResponse,
|
||||||
FeedResponse,
|
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 (
|
from app.schemas.common import (
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
@@ -95,6 +110,20 @@ __all__ = [
|
|||||||
# Activity
|
# Activity
|
||||||
"ActivityResponse",
|
"ActivityResponse",
|
||||||
"FeedResponse",
|
"FeedResponse",
|
||||||
|
# Event
|
||||||
|
"EventCreate",
|
||||||
|
"EventResponse",
|
||||||
|
"EventEffects",
|
||||||
|
"ActiveEventResponse",
|
||||||
|
"SwapRequest",
|
||||||
|
"SwapCandidate",
|
||||||
|
"CommonEnemyLeaderboard",
|
||||||
|
"EVENT_INFO",
|
||||||
|
"COMMON_ENEMY_BONUSES",
|
||||||
|
"SwapRequestCreate",
|
||||||
|
"SwapRequestResponse",
|
||||||
|
"SwapRequestChallengeInfo",
|
||||||
|
"MySwapRequests",
|
||||||
# Common
|
# Common
|
||||||
"MessageResponse",
|
"MessageResponse",
|
||||||
"ErrorResponse",
|
"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
|
start_date: datetime | None = None
|
||||||
is_public: bool | None = None
|
is_public: bool | None = None
|
||||||
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
||||||
|
auto_events_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ParticipantInfo(BaseModel):
|
class ParticipantInfo(BaseModel):
|
||||||
@@ -47,6 +48,7 @@ class MarathonResponse(MarathonBase):
|
|||||||
invite_code: str
|
invite_code: str
|
||||||
is_public: bool
|
is_public: bool
|
||||||
game_proposal_mode: str
|
game_proposal_mode: str
|
||||||
|
auto_events_enabled: bool
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
participants_count: int
|
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:
|
class PointsService:
|
||||||
"""Service for calculating points and penalties"""
|
"""Service for calculating points and penalties"""
|
||||||
|
|
||||||
@@ -17,39 +20,77 @@ class PointsService:
|
|||||||
}
|
}
|
||||||
MAX_DROP_PENALTY = 50
|
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(
|
def calculate_completion_points(
|
||||||
self,
|
self,
|
||||||
base_points: int,
|
base_points: int,
|
||||||
current_streak: int
|
current_streak: int,
|
||||||
) -> tuple[int, int]:
|
event: Event | None = None,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
"""
|
"""
|
||||||
Calculate points earned for completing a challenge.
|
Calculate points earned for completing a challenge.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_points: Base points for the challenge
|
base_points: Base points for the challenge
|
||||||
current_streak: Current streak before this completion
|
current_streak: Current streak before this completion
|
||||||
|
event: Active event (optional)
|
||||||
|
|
||||||
Returns:
|
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,
|
current_streak,
|
||||||
self.MAX_STREAK_MULTIPLIER
|
self.MAX_STREAK_MULTIPLIER
|
||||||
)
|
)
|
||||||
bonus = int(base_points * multiplier)
|
streak_bonus = int(adjusted_base * streak_multiplier)
|
||||||
return base_points + bonus, bonus
|
|
||||||
|
|
||||||
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.
|
Calculate penalty for dropping a challenge.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
consecutive_drops: Number of drops since last completion
|
consecutive_drops: Number of drops since last completion
|
||||||
|
event: Active event (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Penalty points to subtract
|
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(
|
return self.DROP_PENALTIES.get(
|
||||||
consecutive_drops,
|
consecutive_drops,
|
||||||
self.MAX_DROP_PENALTY
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN npm ci --network-timeout 300000
|
||||||
|
|
||||||
# Copy source
|
# Copy source
|
||||||
COPY . .
|
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 { wheelApi } from './wheel'
|
||||||
export { feedApi } from './feed'
|
export { feedApi } from './feed'
|
||||||
export { adminApi } from './admin'
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||||
import type { Marathon } from '@/types'
|
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { Button, Card, CardContent } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
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'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
export function MarathonPage() {
|
export function MarathonPage() {
|
||||||
@@ -12,10 +14,13 @@ export function MarathonPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
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 [isLoading, setIsLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [isJoining, setIsJoining] = useState(false)
|
const [isJoining, setIsJoining] = useState(false)
|
||||||
|
const [showEventControl, setShowEventControl] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMarathon()
|
loadMarathon()
|
||||||
@@ -26,6 +31,22 @@ export function MarathonPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await marathonsApi.get(parseInt(id))
|
const data = await marathonsApi.get(parseInt(id))
|
||||||
setMarathon(data)
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load marathon:', error)
|
console.error('Failed to load marathon:', error)
|
||||||
navigate('/marathons')
|
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 = () => {
|
const getInviteLink = () => {
|
||||||
if (!marathon) return ''
|
if (!marathon) return ''
|
||||||
return `${window.location.origin}/invite/${marathon.invite_code}`
|
return `${window.location.origin}/invite/${marathon.invite_code}`
|
||||||
@@ -234,6 +265,42 @@ export function MarathonPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 */}
|
{/* Invite link */}
|
||||||
{marathon.status !== 'finished' && (
|
{marathon.status !== 'finished' && (
|
||||||
<Card className="mb-8">
|
<Card className="mb-8">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, wheelApi, gamesApi } from '@/api'
|
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
|
||||||
import type { Marathon, Assignment, SpinResult, Game } from '@/types'
|
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { Button, Card, CardContent } from '@/components/ui'
|
||||||
import { SpinWheel } from '@/components/SpinWheel'
|
import { SpinWheel } from '@/components/SpinWheel'
|
||||||
import { Loader2, Upload, X } from 'lucide-react'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
|
import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
export function PlayPage() {
|
export function PlayPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -13,6 +14,7 @@ export function PlayPage() {
|
|||||||
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
||||||
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
|
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
|
||||||
const [games, setGames] = useState<Game[]>([])
|
const [games, setGames] = useState<Game[]>([])
|
||||||
|
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
// Complete state
|
// Complete state
|
||||||
@@ -24,23 +26,113 @@ export function PlayPage() {
|
|||||||
// Drop state
|
// Drop state
|
||||||
const [isDropping, setIsDropping] = useState(false)
|
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)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [id])
|
}, [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 () => {
|
const loadData = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const [marathonData, assignment, gamesData] = await Promise.all([
|
const [marathonData, assignment, gamesData, eventData] = await Promise.all([
|
||||||
marathonsApi.get(parseInt(id)),
|
marathonsApi.get(parseInt(id)),
|
||||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||||
gamesApi.list(parseInt(id), 'approved'),
|
gamesApi.list(parseInt(id), 'approved'),
|
||||||
|
eventsApi.getActive(parseInt(id)),
|
||||||
])
|
])
|
||||||
setMarathon(marathonData)
|
setMarathon(marathonData)
|
||||||
setCurrentAssignment(assignment)
|
setCurrentAssignment(assignment)
|
||||||
setGames(gamesData)
|
setGames(gamesData)
|
||||||
|
setActiveEvent(eventData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load data:', error)
|
console.error('Failed to load data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,6 +140,16 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> => {
|
const handleSpin = async (): Promise<Game | null> => {
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
|
|
||||||
@@ -122,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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
@@ -138,8 +326,14 @@ export function PlayPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<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 */}
|
{/* Header stats */}
|
||||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-3">
|
<CardContent className="text-center py-3">
|
||||||
<div className="text-xl font-bold text-primary-500">
|
<div className="text-xl font-bold text-primary-500">
|
||||||
@@ -166,25 +360,144 @@ export function PlayPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No active assignment - show spin wheel */}
|
{/* Active event banner */}
|
||||||
{!currentAssignment && (
|
{activeEvent?.event && (
|
||||||
<Card>
|
<div className="mb-6">
|
||||||
<CardContent className="py-8">
|
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||||
<h2 className="text-2xl font-bold text-white mb-2 text-center">Крутите колесо!</h2>
|
</div>
|
||||||
<p className="text-gray-400 mb-6 text-center">
|
)}
|
||||||
Получите случайную игру и задание для выполнения
|
|
||||||
</p>
|
{/* Common Enemy Leaderboard */}
|
||||||
<SpinWheel
|
{activeEvent?.event?.type === 'common_enemy' && (
|
||||||
games={games}
|
<Card className="mb-6">
|
||||||
onSpin={handleSpin}
|
<CardContent>
|
||||||
onSpinComplete={handleSpinComplete}
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Active assignment */}
|
||||||
{currentAssignment && (
|
{currentAssignment && (
|
||||||
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
@@ -315,6 +628,184 @@ export function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface Marathon {
|
|||||||
invite_code: string
|
invite_code: string
|
||||||
is_public: boolean
|
is_public: boolean
|
||||||
game_proposal_mode: GameProposalMode
|
game_proposal_mode: GameProposalMode
|
||||||
|
auto_events_enabled: boolean
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
end_date: string | null
|
end_date: string | null
|
||||||
participants_count: number
|
participants_count: number
|
||||||
@@ -192,6 +193,58 @@ export interface DropResult {
|
|||||||
new_drop_count: number
|
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
|
// Activity types
|
||||||
export type ActivityType =
|
export type ActivityType =
|
||||||
| 'join'
|
| 'join'
|
||||||
@@ -218,6 +271,78 @@ export interface FeedResponse {
|
|||||||
has_more: boolean
|
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
|
// Admin types
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user