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

@@ -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)