2025-12-15 03:22:29 +07:00
|
|
|
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
|
|
|
|
|
|
2025-12-15 23:03:59 +07:00
|
|
|
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
|
2025-12-15 03:22:29 +07:00
|
|
|
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)
|
2025-12-15 23:03:59 +07:00
|
|
|
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)
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(event)
|
|
|
|
|
|
|
|
|
|
# Load created_by relationship
|
|
|
|
|
if created_by_id:
|
|
|
|
|
await db.refresh(event, ["created_by"])
|
|
|
|
|
|
|
|
|
|
return event
|
|
|
|
|
|
2025-12-15 23:03:59 +07:00
|
|
|
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)
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
async def end_event(self, db: AsyncSession, event_id: int) -> None:
|
2025-12-15 23:03:59 +07:00
|
|
|
"""End an event and mark incomplete event assignments as expired"""
|
|
|
|
|
from sqlalchemy import update
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
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()
|
2025-12-15 23:03:59 +07:00
|
|
|
|
|
|
|
|
# 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(),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
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:
|
2025-12-15 22:31:42 +07:00
|
|
|
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
|
2025-12-15 03:22:29 +07:00
|
|
|
return 0, False, None
|
|
|
|
|
|
|
|
|
|
data = event.data or {}
|
|
|
|
|
completions = data.get("completions", [])
|
2025-12-15 22:31:42 +07:00
|
|
|
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
|
2025-12-15 03:22:29 +07:00
|
|
|
|
|
|
|
|
# Check if already completed
|
|
|
|
|
if any(c["participant_id"] == participant_id for c in completions):
|
2025-12-15 22:31:42 +07:00
|
|
|
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
|
2025-12-15 03:22:29 +07:00
|
|
|
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,
|
|
|
|
|
})
|
2025-12-15 22:31:42 +07:00
|
|
|
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
|
2025-12-15 03:22:29 +07:00
|
|
|
|
|
|
|
|
# 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
|
2025-12-15 22:31:42 +07:00
|
|
|
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
|
2025-12-15 03:22:29 +07:00
|
|
|
|
|
|
|
|
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()
|