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, Participant, Assignment, AssignmentStatus from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES from app.services.telegram_notifier import telegram_notifier 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.flush() # Get event.id before committing # Auto-assign challenge to all participants for Common Enemy if event_type == EventType.COMMON_ENEMY.value and challenge_id: await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id) await db.commit() await db.refresh(event) # Load created_by relationship if created_by_id: await db.refresh(event, ["created_by"]) # Send Telegram notifications result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) marathon = result.scalar_one_or_none() if marathon: await telegram_notifier.notify_event_start( db, marathon_id, event_type, marathon.title ) return event async def _assign_common_enemy_to_all( self, db: AsyncSession, marathon_id: int, event_id: int, challenge_id: int, ) -> None: """Create event assignments for all participants in the marathon""" # Get all participants result = await db.execute( select(Participant).where(Participant.marathon_id == marathon_id) ) participants = result.scalars().all() # Create event assignment for each participant for participant in participants: assignment = Assignment( participant_id=participant.id, challenge_id=challenge_id, status=AssignmentStatus.ACTIVE.value, event_type=EventType.COMMON_ENEMY.value, is_event_assignment=True, event_id=event_id, ) db.add(assignment) async def end_event(self, db: AsyncSession, event_id: int) -> None: """End an event and mark incomplete event assignments as expired""" from sqlalchemy import update result = await db.execute(select(Event).where(Event.id == event_id)) event = result.scalar_one_or_none() if event: event_type = event.type marathon_id = event.marathon_id event.is_active = False if not event.end_time: event.end_time = datetime.utcnow() # Mark all incomplete event assignments for this event as dropped if event.type == EventType.COMMON_ENEMY.value: await db.execute( update(Assignment) .where( Assignment.event_id == event_id, Assignment.is_event_assignment == True, Assignment.status == AssignmentStatus.ACTIVE.value, ) .values( status=AssignmentStatus.DROPPED.value, completed_at=datetime.utcnow(), ) ) await db.commit() # Send Telegram notifications about event end result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) marathon = result.scalar_one_or_none() if marathon: await telegram_notifier.notify_event_end( db, marathon_id, event_type, marathon.title ) 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: print(f"[COMMON_ENEMY] Event type mismatch: {event.type}") return 0, False, None data = event.data or {} completions = data.get("completions", []) print(f"[COMMON_ENEMY] Current completions count: {len(completions)}") # Check if already completed if any(c["participant_id"] == participant_id for c in completions): print(f"[COMMON_ENEMY] Participant {participant_id} already completed") 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, }) print(f"[COMMON_ENEMY] Added completion for user {user_id}, 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 print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}") 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()