Add events

This commit is contained in:
2025-12-15 03:22:29 +07:00
parent 1a882fb2e0
commit 4239ea8516
31 changed files with 7288 additions and 75 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events
router = APIRouter(prefix="/api/v1")
@@ -12,3 +12,4 @@ router.include_router(challenges.router)
router.include_router(wheel.router)
router.include_router(feed.router)
router.include_router(admin.router)
router.include_router(events.router)

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)
async def create_challenge(
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,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
@@ -206,6 +207,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
invite_code=marathon.invite_code,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=participants_count,
@@ -240,6 +242,8 @@ async def update_marathon(
marathon.is_public = data.is_public
if data.game_proposal_mode is not None:
marathon.game_proposal_mode = data.game_proposal_mode
if data.auto_events_enabled is not None:
marathon.auto_events_enabled = data.auto_events_enabled
await db.commit()

View File

@@ -10,13 +10,15 @@ from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType
Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty
)
from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
)
from app.services.points import PointsService
from app.services.events import event_service
router = APIRouter(tags=["wheel"])
@@ -69,46 +71,91 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if active:
raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get all games with challenges
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id)
)
games = [g for g in result.scalars().all() if g.challenges]
# Check active event
active_event = await event_service.get_active_event(db, marathon_id)
if not games:
raise HTTPException(status_code=400, detail="No games with challenges available")
game = None
challenge = None
# Random selection
game = random.choice(games)
challenge = random.choice(game.challenges)
# 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)
# Create assignment
elif active_event.type == EventType.COMMON_ENEMY.value:
# Common enemy: Everyone gets same challenge (if not already completed)
event_data = active_event.data or {}
completions = event_data.get("completions", [])
already_completed = any(c["participant_id"] == participant.id for c in completions)
if not already_completed:
challenge = await event_service.get_common_enemy_challenge(db, active_event)
if challenge:
game = challenge.game
# Normal random selection if no special event handling
if not game or not challenge:
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id)
)
games = [g for g in result.scalars().all() if g.challenges]
if not games:
raise HTTPException(status_code=400, detail="No games with challenges available")
game = random.choice(games)
challenge = random.choice(game.challenges)
# Create assignment (store event_type for jackpot multiplier on completion)
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
status=AssignmentStatus.ACTIVE.value,
event_type=active_event.type if active_event else None,
)
db.add(assignment)
# Log activity
activity_data = {
"game": game.title,
"challenge": challenge.title,
}
if active_event:
activity_data["event_type"] = active_event.type
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.SPIN.value,
data={
"game": game.title,
"challenge": challenge.title,
},
data=activity_data,
)
db.add(activity)
await db.commit()
await db.refresh(assignment)
# Calculate drop penalty
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
# Calculate drop penalty (considers active event for double_risk)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
# Get challenges count (avoid lazy loading in async context)
challenges_count = 0
if 'challenges' in game.__dict__:
challenges_count = len(game.challenges)
else:
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
return SpinResult(
assignment_id=assignment.id,
@@ -119,7 +166,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
download_url=game.download_url,
genre=game.genre,
added_by=None,
challenges_count=len(game.challenges),
challenges_count=challenges_count,
created_at=game.created_at,
),
challenge=ChallengeResponse(
@@ -246,9 +293,41 @@ async def complete_assignment(
participant = assignment.participant
challenge = assignment.challenge
total_points, streak_bonus = points_service.calculate_completion_points(
challenge.points, participant.current_streak
# Get marathon_id for activity and event check
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
)
full_challenge = result.scalar_one()
marathon_id = full_challenge.game.marathon_id
# Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id)
# For jackpot/rematch: use the event_type stored in assignment (since event may be over)
# For other events: use the currently active event
effective_event = active_event
# Handle assignment-level event types (jackpot, rematch)
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
# Create a mock event object for point calculation
class MockEvent:
def __init__(self, event_type):
self.type = event_type
effective_event = MockEvent(assignment.event_type)
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
challenge.points, participant.current_streak, effective_event
)
# Handle common enemy bonus
common_enemy_bonus = 0
common_enemy_closed = False
common_enemy_winners = None
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
db, active_event, participant.id, current_user.id
)
total_points += common_enemy_bonus
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -261,25 +340,53 @@ async def complete_assignment(
participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success
# Get marathon_id for activity
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
)
full_challenge = result.scalar_one()
# Log activity
activity_data = {
"challenge": challenge.title,
"points": total_points,
"streak": participant.current_streak,
}
# Log event info (use assignment's event_type for jackpot/rematch, active_event for others)
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus
elif active_event:
activity_data["event_type"] = active_event.type
activity_data["event_bonus"] = event_bonus
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus
activity = Activity(
marathon_id=full_challenge.game.marathon_id,
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data={
"challenge": challenge.title,
"points": total_points,
"streak": participant.current_streak,
},
data=activity_data,
)
db.add(activity)
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True,
"winners": [
{
"user_id": w["user_id"],
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
],
},
)
db.add(event_end_activity)
await db.commit()
return CompleteResult(
@@ -314,9 +421,13 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant
marathon_id = assignment.challenge.game.marathon_id
# Calculate penalty
penalty = points_service.calculate_drop_penalty(participant.drop_count)
# Check active event for free drops (double_risk)
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -328,14 +439,20 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
participant.drop_count += 1
# Log activity
activity_data = {
"challenge": assignment.challenge.title,
"penalty": penalty,
}
if active_event:
activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value:
activity_data["free_drop"] = True
activity = Activity(
marathon_id=assignment.challenge.game.marathon_id,
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data={
"challenge": assignment.challenge.title,
"penalty": penalty,
},
data=activity_data,
)
db.add(activity)

View File

@@ -6,8 +6,9 @@ from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.core.config import settings
from app.core.database import engine, Base
from app.core.database import engine, Base, async_session_maker
from app.api.v1 import router as api_router
from app.services.event_scheduler import event_scheduler
@asynccontextmanager
@@ -22,9 +23,13 @@ async def lifespan(app: FastAPI):
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
# Start event scheduler
await event_scheduler.start(async_session_maker)
yield
# Shutdown
await event_scheduler.stop()
await engine.dispose()

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.assignment import Assignment, AssignmentStatus
from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus
__all__ = [
"User",
@@ -24,4 +26,8 @@ __all__ = [
"AssignmentStatus",
"Activity",
"ActivityType",
"Event",
"EventType",
"SwapRequest",
"SwapRequestStatus",
]

View File

@@ -16,6 +16,10 @@ class ActivityType(str, Enum):
ADD_GAME = "add_game"
APPROVE_GAME = "approve_game"
REJECT_GAME = "reject_game"
EVENT_START = "event_start"
EVENT_END = "event_end"
SWAP = "swap"
REMATCH = "rematch"
class Activity(Base):

View File

@@ -19,6 +19,7 @@ class Assignment(Base):
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)

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)
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
@@ -53,3 +54,8 @@ class Marathon(Base):
back_populates="marathon",
cascade="all, delete-orphan"
)
events: Mapped[list["Event"]] = relationship(
"Event",
back_populates="marathon",
cascade="all, delete-orphan"
)

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,
FeedResponse,
)
from app.schemas.event import (
EventCreate,
EventResponse,
EventEffects,
ActiveEventResponse,
SwapRequest,
SwapCandidate,
CommonEnemyLeaderboard,
EVENT_INFO,
COMMON_ENEMY_BONUSES,
SwapRequestCreate,
SwapRequestResponse,
SwapRequestChallengeInfo,
MySwapRequests,
)
from app.schemas.common import (
MessageResponse,
ErrorResponse,
@@ -95,6 +110,20 @@ __all__ = [
# Activity
"ActivityResponse",
"FeedResponse",
# Event
"EventCreate",
"EventResponse",
"EventEffects",
"ActiveEventResponse",
"SwapRequest",
"SwapCandidate",
"CommonEnemyLeaderboard",
"EVENT_INFO",
"COMMON_ENEMY_BONUSES",
"SwapRequestCreate",
"SwapRequestResponse",
"SwapRequestChallengeInfo",
"MySwapRequests",
# Common
"MessageResponse",
"ErrorResponse",

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
is_public: bool | None = None
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
auto_events_enabled: bool | None = None
class ParticipantInfo(BaseModel):
@@ -47,6 +48,7 @@ class MarathonResponse(MarathonBase):
invite_code: str
is_public: bool
game_proposal_mode: str
auto_events_enabled: bool
start_date: datetime | None
end_date: datetime | None
participants_count: int

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:
"""Service for calculating points and penalties"""
@@ -17,39 +20,77 @@ class PointsService:
}
MAX_DROP_PENALTY = 50
# Event point multipliers
EVENT_MULTIPLIERS = {
EventType.GOLDEN_HOUR.value: 1.5,
EventType.DOUBLE_RISK.value: 0.5,
EventType.JACKPOT.value: 3.0,
EventType.REMATCH.value: 0.5,
}
def calculate_completion_points(
self,
base_points: int,
current_streak: int
) -> tuple[int, int]:
current_streak: int,
event: Event | None = None,
) -> tuple[int, int, int]:
"""
Calculate points earned for completing a challenge.
Args:
base_points: Base points for the challenge
current_streak: Current streak before this completion
event: Active event (optional)
Returns:
Tuple of (total_points, streak_bonus)
Tuple of (total_points, streak_bonus, event_bonus)
"""
multiplier = self.STREAK_MULTIPLIERS.get(
# Apply event multiplier first
event_multiplier = 1.0
if event:
event_multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
adjusted_base = int(base_points * event_multiplier)
event_bonus = adjusted_base - base_points
# Then apply streak bonus
streak_multiplier = self.STREAK_MULTIPLIERS.get(
current_streak,
self.MAX_STREAK_MULTIPLIER
)
bonus = int(base_points * multiplier)
return base_points + bonus, bonus
streak_bonus = int(adjusted_base * streak_multiplier)
def calculate_drop_penalty(self, consecutive_drops: int) -> int:
total_points = adjusted_base + streak_bonus
return total_points, streak_bonus, event_bonus
def calculate_drop_penalty(
self,
consecutive_drops: int,
event: Event | None = None
) -> int:
"""
Calculate penalty for dropping a challenge.
Args:
consecutive_drops: Number of drops since last completion
event: Active event (optional)
Returns:
Penalty points to subtract
"""
# Double risk event = free drops
if event and event.type == EventType.DOUBLE_RISK.value:
return 0
return self.DROP_PENALTIES.get(
consecutive_drops,
self.MAX_DROP_PENALTY
)
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
"""Apply event multiplier to points"""
if not event:
return base_points
multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
return int(base_points * multiplier)