Add events
This commit is contained in:
150
backend/app/services/event_scheduler.py
Normal file
150
backend/app/services/event_scheduler.py
Normal 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()
|
||||
227
backend/app/services/events.py
Normal file
227
backend/app/services/events.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user