Files
game-marathon/backend/app/services/events.py
2025-12-15 23:03:59 +07:00

283 lines
9.9 KiB
Python

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
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"])
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.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()
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()