Compare commits

...

2 Commits

Author SHA1 Message Date
4239ea8516 Add events 2025-12-15 03:50:04 +07:00
1a882fb2e0 Add game roll wheel 2025-12-14 21:41:49 +07:00
32 changed files with 7511 additions and 83 deletions

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

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

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

View File

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

View File

@@ -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,

View 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

View File

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

View File

@@ -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,7 +71,39 @@ 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
active_event = await event_service.get_active_event(db, marathon_id)
game = None
challenge = None
# Handle special event cases
if active_event:
if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
if challenge:
# Load game for challenge
result = await db.execute(
select(Game).where(Game.id == challenge.game_id)
)
game = result.scalar_one_or_none()
# Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id)
elif active_event.type == EventType.COMMON_ENEMY.value:
# Common enemy: Everyone gets same challenge (if not already completed)
event_data = active_event.data or {}
completions = event_data.get("completions", [])
already_completed = any(c["participant_id"] == participant.id for c in completions)
if not already_completed:
challenge = await event_service.get_common_enemy_challenge(db, active_event)
if challenge:
game = challenge.game
# Normal random selection if no special event handling
if not game or not challenge:
result = await db.execute( result = await db.execute(
select(Game) select(Game)
.options(selectinload(Game.challenges)) .options(selectinload(Game.challenges))
@@ -80,35 +114,48 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if not games: if not games:
raise HTTPException(status_code=400, detail="No games with challenges available") raise HTTPException(status_code=400, detail="No games with challenges available")
# Random selection
game = random.choice(games) game = random.choice(games)
challenge = random.choice(game.challenges) challenge = random.choice(game.challenges)
# Create assignment # 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 = Activity( activity_data = {
marathon_id=full_challenge.game.marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data={
"challenge": challenge.title, "challenge": challenge.title,
"points": total_points, "points": total_points,
"streak": participant.current_streak, "streak": participant.current_streak,
}, }
# Log event info (use assignment's event_type for jackpot/rematch, active_event for others)
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus
elif active_event:
activity_data["event_type"] = active_event.type
activity_data["event_bonus"] = event_bonus
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
) )
db.add(activity) 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 = Activity( activity_data = {
marathon_id=assignment.challenge.game.marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data={
"challenge": assignment.challenge.title, "challenge": assignment.challenge.title,
"penalty": penalty, "penalty": penalty,
}, }
if active_event:
activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value:
activity_data["free_drop"] = True
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data=activity_data,
) )
db.add(activity) db.add(activity)

View File

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

View File

@@ -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",
] ]

View File

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

View File

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

View 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")

View File

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

View 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]
)

View File

@@ -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",

View 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]

View File

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

View 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()

View 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()

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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
},
}

View 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
},
}

View File

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

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { Game } from '@/types'
interface SpinWheelProps {
games: Game[]
onSpin: () => Promise<Game | null>
onSpinComplete: (game: Game) => void
disabled?: boolean
}
const ITEM_HEIGHT = 100
const VISIBLE_ITEMS = 5
const SPIN_DURATION = 4000
const EXTRA_ROTATIONS = 3
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
const [isSpinning, setIsSpinning] = useState(false)
const [offset, setOffset] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | null>(null)
// Create extended list for seamless looping
const extendedGames = [...games, ...games, ...games, ...games, ...games]
const handleSpin = useCallback(async () => {
if (isSpinning || disabled || games.length === 0) return
setIsSpinning(true)
// Get result from API first
const resultGame = await onSpin()
if (!resultGame) {
setIsSpinning(false)
return
}
// Find target index
const targetIndex = games.findIndex(g => g.id === resultGame.id)
if (targetIndex === -1) {
setIsSpinning(false)
onSpinComplete(resultGame)
return
}
// Calculate animation
const totalItems = games.length
const fullRotations = EXTRA_ROTATIONS * totalItems
const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT
// Animate
const startTime = Date.now()
const startOffset = offset % (totalItems * ITEM_HEIGHT)
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / SPIN_DURATION, 1)
// Easing function - starts fast, slows down at end
const easeOut = 1 - Math.pow(1 - progress, 4)
const currentOffset = startOffset + (finalPosition - startOffset) * easeOut
setOffset(currentOffset)
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
} else {
setIsSpinning(false)
onSpinComplete(resultGame)
}
}
animationRef.current = requestAnimationFrame(animate)
}, [isSpinning, disabled, games, offset, onSpin, onSpinComplete])
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [])
if (games.length === 0) {
return (
<div className="text-center py-12 text-gray-400">
Нет доступных игр для прокрутки
</div>
)
}
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
// Calculate opacity based on distance from center
const getItemOpacity = (itemIndex: number) => {
const itemPosition = itemIndex * ITEM_HEIGHT - offset
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
const distanceFromCenter = Math.abs(itemPosition - centerPosition)
const maxDistance = containerHeight / 2
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8)
return opacity
}
return (
<div className="flex flex-col items-center gap-6">
{/* Wheel container */}
<div className="relative w-full max-w-md">
{/* Selection indicator */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] border-2 border-primary-500 rounded-lg bg-primary-500/10 z-20 pointer-events-none">
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-t-transparent border-b-transparent border-r-primary-500" />
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-l-8 border-t-transparent border-b-transparent border-l-primary-500" />
</div>
{/* Items container */}
<div
ref={containerRef}
className="relative overflow-hidden"
style={{ height: containerHeight }}
>
<div
className="absolute w-full transition-none"
style={{
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`,
}}
>
{extendedGames.map((game, index) => {
const realIndex = index % games.length
const isSelected = !isSpinning && realIndex === currentIndex
const opacity = getItemOpacity(index)
return (
<div
key={`${game.id}-${index}`}
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${
isSelected ? 'scale-105' : ''
}`}
style={{ height: ITEM_HEIGHT, opacity }}
>
{/* Game cover */}
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0">
{game.cover_url ? (
<img
src={game.cover_url}
alt={game.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">
🎮
</div>
)}
</div>
{/* Game info */}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate text-lg">
{game.title}
</h3>
{game.genre && (
<p className="text-sm text-gray-400 truncate">{game.genre}</p>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
{/* Spin button */}
<button
onClick={handleSpin}
disabled={isSpinning || disabled}
className={`
relative px-12 py-4 text-xl font-bold rounded-full
transition-all duration-300 transform
${isSpinning || disabled
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
: 'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:scale-105 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95'
}
`}
>
{isSpinning ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-6 h-6" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Крутится...
</span>
) : (
'КРУТИТЬ!'
)}
</button>
</div>
)
}

View File

@@ -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">

View File

@@ -1,9 +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 } from '@/api' import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
import type { Marathon, Assignment, SpinResult } 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 { Loader2, Upload, X } from 'lucide-react' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
export function PlayPage() { export function PlayPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -11,11 +13,10 @@ export function PlayPage() {
const [marathon, setMarathon] = useState<Marathon | null>(null) const [marathon, setMarathon] = useState<Marathon | null>(null)
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 [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
// Spin state
const [isSpinning, setIsSpinning] = useState(false)
// Complete state // Complete state
const [proofFile, setProofFile] = useState<File | null>(null) const [proofFile, setProofFile] = useState<File | null>(null)
const [proofUrl, setProofUrl] = useState('') const [proofUrl, setProofUrl] = useState('')
@@ -25,21 +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] = 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'),
eventsApi.getActive(parseInt(id)),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
setGames(gamesData)
setActiveEvent(eventData)
} catch (error) { } catch (error) {
console.error('Failed to load data:', error) console.error('Failed to load data:', error)
} finally { } finally {
@@ -47,24 +140,37 @@ export function PlayPage() {
} }
} }
const handleSpin = async () => { const refreshEvent = async () => {
if (!id) return if (!id) return
try {
const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData)
} catch (error) {
console.error('Failed to refresh event:', error)
}
}
const handleSpin = async (): Promise<Game | null> => {
if (!id) return null
setIsSpinning(true)
setSpinResult(null)
try { try {
const result = await wheelApi.spin(parseInt(id)) const result = await wheelApi.spin(parseInt(id))
setSpinResult(result) setSpinResult(result)
// Reload to get assignment return result.game
await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось крутить') alert(error.response?.data?.detail || 'Не удалось крутить')
} finally { return null
setIsSpinning(false)
} }
} }
const handleSpinComplete = async () => {
// Small delay then reload data to show the assignment
setTimeout(async () => {
await loadData()
}, 500)
}
const handleComplete = async () => { const handleComplete = async () => {
if (!currentAssignment) return if (!currentAssignment) return
if (!proofFile && !proofUrl) { if (!proofFile && !proofUrl) {
@@ -118,6 +224,92 @@ export function PlayPage() {
} }
} }
const handleRematch = async (assignmentId: number) => {
if (!id) return
if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return
setRematchingId(assignmentId)
try {
await eventsApi.rematch(parseInt(id), assignmentId)
alert('Реванш начат! Выполните задание за 50% очков.')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось начать реванш')
} finally {
setRematchingId(null)
}
}
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
if (!id) return
if (!confirm(`Отправить запрос на обмен с ${participantName}?\n\nВы предлагаете обменяться на: "${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`)) return
setSendingRequestTo(participantId)
try {
await eventsApi.createSwapRequest(parseInt(id), participantId)
alert('Запрос на обмен отправлен! Ожидайте подтверждения.')
await loadSwapRequests()
await loadSwapCandidates()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отправить запрос')
} finally {
setSendingRequestTo(null)
}
}
const handleAcceptSwapRequest = async (requestId: number) => {
if (!id) return
if (!confirm('Принять обмен? Задания будут обменяны сразу после подтверждения.')) return
setProcessingRequestId(requestId)
try {
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
alert('Обмен выполнен!')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить обмен')
} finally {
setProcessingRequestId(null)
}
}
const handleDeclineSwapRequest = async (requestId: number) => {
if (!id) return
setProcessingRequestId(requestId)
try {
await eventsApi.declineSwapRequest(parseInt(id), requestId)
await loadSwapRequests()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отклонить запрос')
} finally {
setProcessingRequestId(null)
}
}
const handleCancelSwapRequest = async (requestId: number) => {
if (!id) return
setProcessingRequestId(requestId)
try {
await eventsApi.cancelSwapRequest(parseInt(id), requestId)
await loadSwapRequests()
await loadSwapCandidates()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отменить запрос')
} finally {
setProcessingRequestId(null)
}
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
@@ -134,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">
@@ -162,23 +360,144 @@ export function PlayPage() {
</Card> </Card>
</div> </div>
{/* No active assignment - show spin */} {/* Active event banner */}
{!currentAssignment && ( {activeEvent?.event && (
<Card className="text-center"> <div className="mb-6">
<CardContent className="py-12"> <EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
<h2 className="text-2xl font-bold text-white mb-4">Крутите колесо!</h2> </div>
<p className="text-gray-400 mb-8"> )}
Получите случайную игру и задание для выполнения
</p> {/* Common Enemy Leaderboard */}
<Button size="lg" onClick={handleSpin} isLoading={isSpinning}> {activeEvent?.event?.type === 'common_enemy' && (
{isSpinning ? 'Крутим...' : 'КРУТИТЬ'} <Card className="mb-6">
</Button> <CardContent>
<div className="flex items-center gap-2 mb-4">
<Users className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-bold text-white">Выполнили челлендж</h3>
{commonEnemyLeaderboard.length > 0 && (
<span className="ml-auto text-gray-400 text-sm">
{commonEnemyLeaderboard.length} чел.
</span>
)}
</div>
{commonEnemyLeaderboard.length === 0 ? (
<div className="text-center py-4 text-gray-500">
Пока никто не выполнил. Будь первым!
</div>
) : (
<div className="space-y-2">
{commonEnemyLeaderboard.map((entry) => (
<div
key={entry.participant_id}
className={`
flex items-center gap-3 p-3 rounded-lg
${entry.rank === 1 ? 'bg-yellow-500/20 border border-yellow-500/30' :
entry.rank === 2 ? 'bg-gray-400/20 border border-gray-400/30' :
entry.rank === 3 ? 'bg-orange-600/20 border border-orange-600/30' :
'bg-gray-800'}
`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
${entry.rank === 1 ? 'bg-yellow-500 text-black' :
entry.rank === 2 ? 'bg-gray-400 text-black' :
entry.rank === 3 ? 'bg-orange-600 text-white' :
'bg-gray-700 text-gray-300'}
`}>
{entry.rank && entry.rank <= 3 ? (
<Trophy className="w-4 h-4" />
) : (
entry.rank
)}
</div>
<div className="flex-1">
<p className="text-white font-medium">{entry.user.nickname}</p>
</div>
{entry.bonus_points > 0 && (
<span className="text-green-400 text-sm font-medium">
+{entry.bonus_points} бонус
</span>
)}
</div>
))}
</div>
)}
</CardContent> </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">
@@ -309,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>
) )

View File

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